From 95a06a073f09408ebbf41f5136733a56bf9a5501 Mon Sep 17 00:00:00 2001 From: Justin Mester Date: Wed, 18 Mar 2026 08:35:37 +0100 Subject: [PATCH 1/5] Add macOS NSFileProvider VFS plugin for Files on Demand Implement a new VFS backend (mode: MacOSNSFileProvider/nsfp) that uses Apple's NSFileProvider framework to provide native Files on Demand support on macOS 12+. Architecture: - VfsNSFP plugin: manages NSFileProvider domain lifecycle, syncs file metadata and access tokens to the App Group shared container, and triggers periodic sync cycles via a 30-second poll timer. - FileProvider extension (appex): runs as a separate sandboxed process, enumerates items from a shared plist, downloads files via direct WebDAV requests, and uploads new files via HTTP PUT/MKCOL. - NsfpDomainManager: handles async domain registration/removal with fileproviderd, including stale domain cleanup and fallback recovery. - NsfpXpcHandler: anonymous NSXPCListener for future bidirectional communication between the main app and the extension. Key features: - Transparent file hydration with real download progress in Finder - Direct WebDAV upload for files dragged into the virtual folder - Automatic metadata refresh after each sync cycle - Server-side deletion detection for NSFP-managed files in discovery - Stale item cleanup on HTTP 404 during fetch - Change tracking with didDeleteItemsWithIdentifiers for Finder updates - User-friendly localized error messages - Token refresh handling with race condition prevention Changes to existing code: - vfs.h/cpp: add MacOSNSFileProvider mode enum and VfsSetupParams constructor with spaceId/displayName/syncEngine - discovery.cpp: handle NSFP files during remote deletion detection - syncjournaldb.cpp: add getFileRecordsByFileId helper - httpcredentials.h: expose accessToken for shared container config - CMakeLists.txt: wire up extension and plugin build targets --- CMakeLists.txt | 9 + src/CMakeLists.txt | 5 + src/cmd/CMakeLists.txt | 6 + src/cmd/cmd.cpp | 15 + src/extensions/fileprovider/CMakeLists.txt | 114 +++ .../fileprovider/FileProviderEnumerator.h | 26 + .../fileprovider/FileProviderEnumerator.mm | 305 ++++++ .../fileprovider/FileProviderItem.h | 61 ++ .../fileprovider/FileProviderItem.mm | 238 +++++ .../fileprovider/FileProviderThumbnails.h | 35 + .../fileprovider/FileProviderThumbnails.mm | 204 ++++ .../fileprovider/FileProviderXPCService.h | 140 +++ .../fileprovider/FileProviderXPCService.mm | 183 ++++ src/extensions/fileprovider/Info.plist.in | 39 + .../OpenCloudFileProvider.entitlements | 14 + .../OpenCloudFileProvider.entitlements.in | 20 + .../OpenCloudFileProviderExtension.h | 22 + .../OpenCloudFileProviderExtension.mm | 944 +++++++++++++++++ src/gui/CMakeLists.txt | 9 +- src/libsync/common/syncjournaldb.cpp | 8 + src/libsync/creds/httpcredentials.h | 2 + src/libsync/discovery.cpp | 21 +- src/libsync/vfs/vfs.cpp | 18 +- src/libsync/vfs/vfs.h | 2 +- src/plugins/vfs/nsfp/CMakeLists.txt | 24 + src/plugins/vfs/nsfp/nsfpdomainmanager.h | 85 ++ src/plugins/vfs/nsfp/nsfpdomainmanager.mm | 431 ++++++++ src/plugins/vfs/nsfp/nsfpxpchandler.h | 53 + src/plugins/vfs/nsfp/nsfpxpchandler.mm | 507 ++++++++++ src/plugins/vfs/nsfp/vfs_nsfp.h | 85 ++ src/plugins/vfs/nsfp/vfs_nsfp.json | 4 + src/plugins/vfs/nsfp/vfs_nsfp.mm | 946 ++++++++++++++++++ test/CMakeLists.txt | 6 + test/testvfsnsfp.cpp | 308 ++++++ test/testvfsnsfp_integration.cpp | 88 ++ 35 files changed, 4971 insertions(+), 6 deletions(-) create mode 100644 src/extensions/fileprovider/CMakeLists.txt create mode 100644 src/extensions/fileprovider/FileProviderEnumerator.h create mode 100644 src/extensions/fileprovider/FileProviderEnumerator.mm create mode 100644 src/extensions/fileprovider/FileProviderItem.h create mode 100644 src/extensions/fileprovider/FileProviderItem.mm create mode 100644 src/extensions/fileprovider/FileProviderThumbnails.h create mode 100644 src/extensions/fileprovider/FileProviderThumbnails.mm create mode 100644 src/extensions/fileprovider/FileProviderXPCService.h create mode 100644 src/extensions/fileprovider/FileProviderXPCService.mm create mode 100644 src/extensions/fileprovider/Info.plist.in create mode 100644 src/extensions/fileprovider/OpenCloudFileProvider.entitlements create mode 100644 src/extensions/fileprovider/OpenCloudFileProvider.entitlements.in create mode 100644 src/extensions/fileprovider/OpenCloudFileProviderExtension.h create mode 100644 src/extensions/fileprovider/OpenCloudFileProviderExtension.mm create mode 100644 src/plugins/vfs/nsfp/CMakeLists.txt create mode 100644 src/plugins/vfs/nsfp/nsfpdomainmanager.h create mode 100644 src/plugins/vfs/nsfp/nsfpdomainmanager.mm create mode 100644 src/plugins/vfs/nsfp/nsfpxpchandler.h create mode 100644 src/plugins/vfs/nsfp/nsfpxpchandler.mm create mode 100644 src/plugins/vfs/nsfp/vfs_nsfp.h create mode 100644 src/plugins/vfs/nsfp/vfs_nsfp.json create mode 100644 src/plugins/vfs/nsfp/vfs_nsfp.mm create mode 100644 test/testvfsnsfp.cpp create mode 100644 test/testvfsnsfp_integration.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7d8bcfe124..360d73f253 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,6 +3,10 @@ cmake_minimum_required(VERSION 3.18) include(VERSION.cmake) project(OpenCloudDesktop LANGUAGES CXX VERSION ${MIRALL_VERSION_MAJOR}.${MIRALL_VERSION_MINOR}.${MIRALL_VERSION_PATCH}) +if(APPLE) + enable_language(OBJCXX) +endif() + set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -104,6 +108,11 @@ option(WITH_EXTERNAL_BRANDING "A URL to an external branding repo" "") # specify additional vfs plugins set(VIRTUAL_FILE_SYSTEM_PLUGINS off cfapi openvfs CACHE STRING "Name of internal plugin in src/libsync/vfs or the locations of virtual file plugins") +# On macOS 12+ (Darwin 21+), add the NSFileProvider-based VFS plugin +if(APPLE AND CMAKE_SYSTEM_VERSION VERSION_GREATER_EQUAL "21.0") + list(APPEND VIRTUAL_FILE_SYSTEM_PLUGINS nsfp) +endif() + if(APPLE) set( SOCKETAPI_TEAM_IDENTIFIER_PREFIX "" CACHE STRING "SocketApi prefix (including a following dot) that must match the codesign key's TeamIdentifier/Organizational Unit" ) endif() diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6b7eb6c33b..98ebad179f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -45,6 +45,11 @@ endif() add_subdirectory(plugins) +# On macOS 12+ (Darwin 21+), build the File Provider App Extension for Files On Demand +if(APPLE AND CMAKE_SYSTEM_VERSION VERSION_GREATER_EQUAL "21.0") + add_subdirectory(extensions/fileprovider) +endif() + install(EXPORT ${APPLICATION_SHORTNAME}Config DESTINATION "${KDE_INSTALL_CMAKEPACKAGEDIR}/${APPLICATION_SHORTNAME}" NAMESPACE OpenCloud::) ecm_setup_version(PROJECT diff --git a/src/cmd/CMakeLists.txt b/src/cmd/CMakeLists.txt index 8f63693cef..6f855a2602 100644 --- a/src/cmd/CMakeLists.txt +++ b/src/cmd/CMakeLists.txt @@ -10,6 +10,12 @@ apply_common_target_settings(cmd) if(APPLE) + # NSFileProvider diagnostic command (VOD-027) + enable_language(OBJCXX) + target_sources(cmd PRIVATE nsfpdiagnostic.mm nsfpdiagnostic.h) + target_compile_options(cmd PRIVATE -fobjc-arc) + target_link_libraries(cmd "-framework Foundation" "-framework FileProvider") + set_target_properties(cmd PROPERTIES RUNTIME_OUTPUT_DIRECTORY "$") else() install(TARGETS cmd ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) diff --git a/src/cmd/cmd.cpp b/src/cmd/cmd.cpp index a8831fd656..ee9503b008 100644 --- a/src/cmd/cmd.cpp +++ b/src/cmd/cmd.cpp @@ -36,6 +36,10 @@ #include +#if defined(Q_OS_MACOS) +#include "cmd/nsfpdiagnostic.h" +#endif + using namespace OCC; @@ -311,6 +315,11 @@ CmdOptions parseOptions(const QStringList &app_args) const auto testCrashReporter = addOption({{QStringLiteral("crash")}, QStringLiteral("Crash the client to test the crash reporter")}, QCommandLineOption::HiddenFromHelp); +#if defined(Q_OS_MACOS) + auto dumpNsfpDomainsOption = + addOption({{QStringLiteral("dump-nsfp-domains")}, QStringLiteral("Dump all registered NSFileProvider domains and exit (macOS only)")}); +#endif + auto verbosityOption = addOption({{QStringLiteral("verbose")}, QStringLiteral("Specify the [verbosity]\n0: no logging (default)\n" "1: general logging\n" @@ -331,6 +340,12 @@ CmdOptions parseOptions(const QStringList &app_args) parser.process(app_args); +#if defined(Q_OS_MACOS) + // Handle --dump-nsfp-domains early: no server URL or credentials needed. + if (parser.isSet(dumpNsfpDomainsOption)) { + exit(OCC::dumpNSFileProviderDomains()); + } +#endif const int verbosity = parser.value(verbosityOption).toInt(); if (verbosity >= 0 && verbosity <= 3) { diff --git a/src/extensions/fileprovider/CMakeLists.txt b/src/extensions/fileprovider/CMakeLists.txt new file mode 100644 index 0000000000..912441dde9 --- /dev/null +++ b/src/extensions/fileprovider/CMakeLists.txt @@ -0,0 +1,114 @@ +# CMake build configuration for the macOS File Provider App Extension. +# Builds an .appex bundle implementing NSFileProviderReplicatedExtension +# for Files On Demand support on macOS 12+. + +if(APPLE) + enable_language(OBJCXX) + + set(FILEPROVIDER_EXTENSION_SOURCES + OpenCloudFileProviderExtension.mm + FileProviderXPCService.mm + FileProviderItem.mm + FileProviderEnumerator.mm + FileProviderThumbnails.mm + ) + + set(FILEPROVIDER_EXTENSION_HEADERS + OpenCloudFileProviderExtension.h + FileProviderXPCService.h + FileProviderItem.h + FileProviderEnumerator.h + FileProviderThumbnails.h + ) + + # Must be add_executable (not add_library MODULE) so the Mach-O filetype + # is MH_EXECUTE (2) rather than MH_BUNDLE (8). macOS requires app + # extensions to be executables for sandbox entitlements to be embedded. + add_executable(OpenCloudFileProviderExtension + ${FILEPROVIDER_EXTENSION_SOURCES} + ${FILEPROVIDER_EXTENSION_HEADERS} + ) + + # Mark as an App Extension bundle (.appex) + set_target_properties(OpenCloudFileProviderExtension PROPERTIES + BUNDLE_EXTENSION "appex" + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist.in" + MACOSX_BUNDLE_BUNDLE_NAME "OpenCloudFileProvider" + MACOSX_BUNDLE_BUNDLE_VERSION "${MIRALL_VERSION_FULL}" + MACOSX_BUNDLE_SHORT_VERSION_STRING "${MIRALL_VERSION}" + MACOSX_BUNDLE_GUI_IDENTIFIER "${APPLICATION_REV_DOMAIN}.fileprovider" + XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_CURRENT_SOURCE_DIR}/OpenCloudFileProvider.entitlements" + XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "$(CODE_SIGN_IDENTITY)" + XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "P4D766R5ZA" + XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${APPLICATION_REV_DOMAIN}.fileprovider" + XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME YES + ) + + # App Extension compile flag: restricts API surface to extension-safe APIs + target_compile_options(OpenCloudFileProviderExtension PRIVATE + -fapplication-extension + -fobjc-arc + ) + + # Link against required Apple frameworks + target_link_libraries(OpenCloudFileProviderExtension PRIVATE + "-framework CoreGraphics" + "-framework Foundation" + "-framework FileProvider" + "-framework UniformTypeIdentifiers" + ) + + # Linker flag for extension-safe linking + target_link_options(OpenCloudFileProviderExtension PRIVATE + -fapplication-extension + -e _NSExtensionMain + ) + + # Set deployment target to macOS 12+ (minimum for NSFileProviderReplicatedExtension) + set_target_properties(OpenCloudFileProviderExtension PROPERTIES + XCODE_ATTRIBUTE_MACOSX_DEPLOYMENT_TARGET "12.0" + ) + + # Embed the extension in the main application bundle under PlugIns/ + # XCODE_EMBED_APP_EXTENSIONS only works with Xcode generator. + # For Ninja/Makefile generators, use a post-build copy step. + if(TARGET ${APPLICATION_EXECUTABLE}) + if(CMAKE_GENERATOR STREQUAL "Xcode") + set_target_properties(${APPLICATION_EXECUTABLE} PROPERTIES + XCODE_EMBED_APP_EXTENSIONS OpenCloudFileProviderExtension + ) + else() + add_custom_command(TARGET OpenCloudFileProviderExtension POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "$" + "$/../PlugIns/OpenCloudFileProviderExtension.appex" + COMMENT "Embedding FileProviderExtension.appex into app bundle" + ) + # Sign the extension with entitlements (XCODE_ATTRIBUTE_* only works + # with the Xcode generator, so Ninja/Make need an explicit codesign). + if(DEFINED CODESIGN_IDENTITY AND NOT CODESIGN_IDENTITY STREQUAL "") + add_custom_command(TARGET OpenCloudFileProviderExtension POST_BUILD + COMMAND codesign --force --sign "${CODESIGN_IDENTITY}" + --entitlements "${CMAKE_CURRENT_SOURCE_DIR}/OpenCloudFileProvider.entitlements" + "$" + COMMAND codesign --force --sign "${CODESIGN_IDENTITY}" + --entitlements "${CMAKE_CURRENT_SOURCE_DIR}/OpenCloudFileProvider.entitlements" + "$/../PlugIns/OpenCloudFileProviderExtension.appex" + COMMENT "Code-signing FileProviderExtension with sandbox entitlements" + ) + endif() + endif() + endif() + + # Notarisation: After archiving, run `xcrun notarytool submit --apple-id ...` + # to notarise the signed application bundle. This is handled by the release pipeline, + # not as part of the CMake build. Hardened Runtime (set above) is required for + # successful notarisation. + + # Install extension into the app bundle PlugIns directory + install(TARGETS OpenCloudFileProviderExtension + RUNTIME DESTINATION "${KDE_INSTALL_BUNDLEDIR}/${APPLICATION_NAME}.app/Contents/PlugIns" + BUNDLE DESTINATION "${KDE_INSTALL_BUNDLEDIR}/${APPLICATION_NAME}.app/Contents/PlugIns" + ) +endif() diff --git a/src/extensions/fileprovider/FileProviderEnumerator.h b/src/extensions/fileprovider/FileProviderEnumerator.h new file mode 100644 index 0000000000..6932d0d7f6 --- /dev/null +++ b/src/extensions/fileprovider/FileProviderEnumerator.h @@ -0,0 +1,26 @@ +// FileProviderEnumerator -- NSFileProviderEnumerator implementation that serves +// directory listings to the macOS File Provider system via XPC. +#pragma once + +#import +#import + +@class FileProviderXPCService; + +NS_ASSUME_NONNULL_BEGIN + +/// Enumerates items within a given container (directory) for the File Provider +/// framework. Obtains item listings from the main application via XPC since +/// the extension runs in a separate process without direct sync journal access. +API_AVAILABLE(macos(12.0)) +@interface FileProviderEnumerator : NSObject + +/// Designated initializer. +/// @param containerId The identifier of the container to enumerate +/// (e.g. root container or a folder's file ID). +/// @param service The XPC service used to communicate with the main app. +- (instancetype)initWithContainerIdentifier:(NSFileProviderItemIdentifier)containerId xpcService:(FileProviderXPCService *)service; + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/extensions/fileprovider/FileProviderEnumerator.mm b/src/extensions/fileprovider/FileProviderEnumerator.mm new file mode 100644 index 0000000000..9a246dda44 --- /dev/null +++ b/src/extensions/fileprovider/FileProviderEnumerator.mm @@ -0,0 +1,305 @@ +// FileProviderEnumerator -- NSFileProviderEnumerator implementation. +// Serves directory listings to Finder by reading file metadata from the +// App Group shared container (written by the main app's sync engine). + +#import "FileProviderEnumerator.h" + +#import "FileProviderItem.h" +#import "FileProviderXPCService.h" + +#import + +static os_log_t enumeratorLog(void) { + static os_log_t log = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + log = os_log_create("eu.opencloud.desktop.fileprovider", "enumerator"); + }); + return log; +} + +/// Appends a trace line to the debug log file in the App Group container. +static void appendTrace(NSString *line) { + NSURL *container = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!container) return; + NSString *path = [[container URLByAppendingPathComponent:@"fp_debug.log"] path]; + NSFileHandle *fh = [NSFileHandle fileHandleForWritingAtPath:path]; + if (!fh) { + [[NSFileManager defaultManager] createFileAtPath:path contents:nil attributes:nil]; + fh = [NSFileHandle fileHandleForWritingAtPath:path]; + } + [fh seekToEndOfFile]; + [fh writeData:[line dataUsingEncoding:NSUTF8StringEncoding]]; + [fh closeFile]; +} + +/// Reads the shared metadata plist from the App Group container. +/// Returns an array of NSDictionary items, or nil if unavailable. +static NSArray *readSharedMetadata(void) { + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) { + os_log_error(enumeratorLog(), "Cannot access App Group container"); + return nil; + } + + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; + NSData *data = [NSData dataWithContentsOfURL:metadataURL]; + if (!data) { + os_log_error(enumeratorLog(), "Shared metadata file not found at: %{public}@", metadataURL.path); + return nil; + } + + NSError *readError = nil; + NSArray *items = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListImmutable + format:nil + error:&readError]; + if (!items || readError) { + os_log_error(enumeratorLog(), "Failed to read shared metadata: %{public}@", + readError.localizedDescription); + return nil; + } + + return items; +} + +#pragma mark - FileProviderEnumerator + +API_AVAILABLE(macos(12.0)) +@implementation FileProviderEnumerator { + NSFileProviderItemIdentifier _containerId; + FileProviderXPCService *_xpcService; + BOOL _invalidated; +} + +- (instancetype)initWithContainerIdentifier:(NSFileProviderItemIdentifier)containerId + xpcService:(FileProviderXPCService *)service { + self = [super init]; + if (self) { + _containerId = [containerId copy]; + _xpcService = service; + _invalidated = NO; + + os_log_fault(enumeratorLog(), ">>> Enumerator CREATED for container: %{public}@", containerId); + } + return self; +} + +#pragma mark - NSFileProviderEnumerator + +- (void)enumerateItemsForObserver:(id)observer + startingAtPage:(NSFileProviderPage)page { + // Use fault-level logging to ensure it's always persisted + os_log_fault(enumeratorLog(), ">>> enumerateItems CALLED container=%{public}@ invalidated=%d", _containerId, _invalidated); + + appendTrace([NSString stringWithFormat:@"[%@] enumerateItems container=%@ invalidated=%d\n", + [NSDate date], _containerId, _invalidated]); + + if (_invalidated) { + [observer finishEnumeratingWithError: + [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: @"Enumerator has been invalidated"}]]; + return; + } + + os_log_info(enumeratorLog(), "enumerateItems container=%{public}@", _containerId); + + // Read file metadata from the App Group shared container. + NSArray *allItems = readSharedMetadata(); + if (!allItems) { + os_log_error(enumeratorLog(), "No shared metadata available — main app may not be running"); + [observer didEnumerateItems:@[]]; + [observer finishEnumeratingWithError:nil]; + return; + } + + // Determine which items belong to this container. + NSString *targetParentPath = nil; + + if ([_containerId isEqualToString:NSFileProviderRootContainerItemIdentifier]) { + // Root container: items whose parentPath is empty. + targetParentPath = @""; + } else if ([_containerId isEqualToString:NSFileProviderWorkingSetContainerItemIdentifier]) { + // Working set: return all items. + targetParentPath = nil; // nil means "all items" + } else if ([_containerId isEqualToString:NSFileProviderTrashContainerItemIdentifier]) { + // Trash: return empty (no trashed items tracked). + os_log_info(enumeratorLog(), "Enumerated 0 items for trash container"); + [observer didEnumerateItems:@[]]; + [observer finishEnumeratingWithError:nil]; + return; + } else { + // Specific folder: find the folder's path by its fileId, + // then list items whose parentPath matches that path. + for (NSDictionary *item in allItems) { + if ([item[@"fileId"] isEqualToString:_containerId]) { + targetParentPath = item[@"path"]; + break; + } + } + if (!targetParentPath) { + os_log_error(enumeratorLog(), "Container not found in metadata: %{public}@", _containerId); + [observer didEnumerateItems:@[]]; + [observer finishEnumeratingWithError:nil]; + return; + } + } + + // Filter items by parent path. + NSMutableArray *providerItems = [NSMutableArray array]; + for (NSDictionary *dict in allItems) { + if (targetParentPath == nil) { + // Working set: include all items. + } else if (![dict[@"parentPath"] isEqualToString:targetParentPath]) { + continue; + } + + FileProviderItem *item = [[FileProviderItem alloc] initWithDictionary:dict]; + [providerItems addObject:item]; + } + + os_log_info(enumeratorLog(), "Enumerated %lu items for container %{public}@", + (unsigned long)providerItems.count, _containerId); + + appendTrace([NSString stringWithFormat:@"[%@] enumerateItems RESULT container=%@ items=%lu allItems=%lu\n", + [NSDate date], _containerId, (unsigned long)providerItems.count, (unsigned long)allItems.count]); + + [observer didEnumerateItems:providerItems]; + [observer finishEnumeratingWithError:nil]; +} + +- (void)enumerateChangesForObserver:(id)observer + fromSyncAnchor:(NSFileProviderSyncAnchor)anchor { + os_log_fault(enumeratorLog(), ">>> enumerateChanges CALLED container=%{public}@", _containerId); + + { + NSString *inAnchorStr = anchor ? [[NSString alloc] initWithData:anchor encoding:NSUTF8StringEncoding] : @"(nil)"; + appendTrace([NSString stringWithFormat:@"[%@] enumerateChanges container=%@ anchor=%@\n", + [NSDate date], _containerId, inAnchorStr]); + } + + os_log_info(enumeratorLog(), "enumerateChanges container=%{public}@", _containerId); + + // Read current metadata to build a content-based sync anchor. + NSArray *allItems = readSharedMetadata(); + NSString *currentAnchorString = @"empty"; + if (allItems) { + // Use item count + latest modtime as a simple content anchor. + int64_t latestModtime = 0; + for (NSDictionary *dict in allItems) { + int64_t mt = [dict[@"modtime"] longLongValue]; + if (mt > latestModtime) latestModtime = mt; + } + currentAnchorString = [NSString stringWithFormat:@"%lu-%lld", + (unsigned long)allItems.count, latestModtime]; + } + NSData *currentAnchor = [currentAnchorString dataUsingEncoding:NSUTF8StringEncoding]; + + // Compare with incoming anchor. If different (or first call), report all items as updates. + NSString *incomingAnchorString = anchor ? [[NSString alloc] initWithData:anchor encoding:NSUTF8StringEncoding] : @""; + + // Always report all items as updates. The system may have a cached anchor + // from a previous run where items were enumerated but not successfully stored + // (e.g. due to a crash). Reporting all items is idempotent — fileproviderd + // will reconcile against its database. + os_log_info(enumeratorLog(), "enumerateChanges: reporting all items (incoming=%{public}@ current=%{public}@)", + incomingAnchorString, currentAnchorString); + + if (allItems) { + // Filter items for this container and report them as updates. + NSMutableArray *updatedItems = [NSMutableArray array]; + NSMutableSet *currentFileIds = [NSMutableSet set]; + NSString *targetParentPath = nil; + + if ([_containerId isEqualToString:NSFileProviderRootContainerItemIdentifier]) { + targetParentPath = @""; + } else if ([_containerId isEqualToString:NSFileProviderWorkingSetContainerItemIdentifier]) { + targetParentPath = nil; + } else if ([_containerId isEqualToString:NSFileProviderTrashContainerItemIdentifier]) { + // Trash: no changes. + [observer finishEnumeratingChangesUpToSyncAnchor:currentAnchor moreComing:NO]; + return; + } else { + for (NSDictionary *item in allItems) { + if ([item[@"fileId"] isEqualToString:_containerId]) { + targetParentPath = item[@"path"]; + break; + } + } + } + + for (NSDictionary *dict in allItems) { + if (targetParentPath != nil && ![dict[@"parentPath"] isEqualToString:targetParentPath]) { + continue; + } + FileProviderItem *item = [[FileProviderItem alloc] initWithDictionary:dict]; + [updatedItems addObject:item]; + [currentFileIds addObject:dict[@"fileId"] ?: @""]; + } + + os_log_info(enumeratorLog(), "enumerateChanges: reporting %lu updated items for %{public}@", + (unsigned long)updatedItems.count, _containerId); + + if (updatedItems.count > 0) { + [observer didUpdateItems:updatedItems]; + } + + // Detect deleted items by comparing current fileIds with the set from the + // previous enumerateChanges call. Report deletions so fileproviderd removes + // them from Finder. + NSString *cacheKey = [NSString stringWithFormat:@"prevFileIds_%@", + [_containerId stringByReplacingOccurrencesOfString:@"/" withString:@"_"]]; + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (containerURL) { + NSURL *cacheURL = [containerURL URLByAppendingPathComponent: + [NSString stringWithFormat:@"%@.plist", cacheKey]]; + NSArray *previousIds = [NSArray arrayWithContentsOfURL:cacheURL]; + if (previousIds) { + NSMutableSet *previousSet = [NSMutableSet setWithArray:previousIds]; + [previousSet minusSet:currentFileIds]; + if (previousSet.count > 0) { + NSArray *deletedIds = [previousSet allObjects]; + os_log_info(enumeratorLog(), "enumerateChanges: reporting %lu deleted items for %{public}@", + (unsigned long)deletedIds.count, _containerId); + [observer didDeleteItemsWithIdentifiers:deletedIds]; + } + } + // Save current set for next comparison. + [[currentFileIds allObjects] writeToURL:cacheURL atomically:YES]; + } + } + + [observer finishEnumeratingChangesUpToSyncAnchor:currentAnchor moreComing:NO]; +} + +- (void)currentSyncAnchorWithCompletionHandler:(void (^)(NSFileProviderSyncAnchor _Nullable))completionHandler { + // Build a content-based anchor from the shared metadata. + NSArray *allItems = readSharedMetadata(); + NSString *anchorString = @"empty"; + if (allItems) { + int64_t latestModtime = 0; + for (NSDictionary *dict in allItems) { + int64_t mt = [dict[@"modtime"] longLongValue]; + if (mt > latestModtime) latestModtime = mt; + } + anchorString = [NSString stringWithFormat:@"%lu-%lld", + (unsigned long)allItems.count, latestModtime]; + } + NSData *anchorData = [anchorString dataUsingEncoding:NSUTF8StringEncoding]; + + os_log_info(enumeratorLog(), "currentSyncAnchor: %{public}@", anchorString); + + completionHandler(anchorData); +} + +- (void)invalidate { + os_log_info(enumeratorLog(), "Enumerator invalidated for container: %{public}@", _containerId); + _invalidated = YES; + _xpcService = nil; +} + +@end diff --git a/src/extensions/fileprovider/FileProviderItem.h b/src/extensions/fileprovider/FileProviderItem.h new file mode 100644 index 0000000000..447b799a00 --- /dev/null +++ b/src/extensions/fileprovider/FileProviderItem.h @@ -0,0 +1,61 @@ +// FileProviderItem -- NSFileProviderItem adapter wrapping sync journal data +// for the macOS File Provider extension. +#pragma once + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Objective-C class conforming to NSFileProviderItem that wraps sync journal +/// data into the form expected by the macOS File Provider framework. +/// +/// Since the extension runs in a separate process with no direct Qt access, +/// all data is stored as Objective-C types (NSString, NSDate, NSNumber). +API_AVAILABLE(macos(12.0)) +@interface FileProviderItem : NSObject + +#pragma mark - NSFileProviderItem required properties + +@property (nonatomic, readonly, copy) NSFileProviderItemIdentifier itemIdentifier; +@property (nonatomic, readonly, copy) NSFileProviderItemIdentifier parentItemIdentifier; +@property (nonatomic, readonly, copy) NSString *filename; +@property (nonatomic, readonly, copy) NSString *typeIdentifier; +@property (nonatomic, readonly, copy) UTType *contentType; +@property (nonatomic, readonly) NSFileProviderItemCapabilities capabilities; +@property (nonatomic, readonly, nullable) NSNumber *documentSize; +@property (nonatomic, readonly, nullable) NSDate *contentModificationDate; +@property (nonatomic, readonly, nullable) NSDate *creationDate; +@property (nonatomic, readonly, nullable) NSNumber *childItemCount; +@property (nonatomic, readonly) NSFileProviderItemVersion *itemVersion; + +#pragma mark - Transfer state properties + +@property (nonatomic, readonly) BOOL isUploaded; +@property (nonatomic, readonly) BOOL isDownloaded; +@property (nonatomic, readonly) BOOL isDownloading; +@property (nonatomic, readonly) BOOL isUploading; + +#pragma mark - Directory properties + +#pragma mark - Initializers + +/// Designated initializer using a dictionary of metadata (typically received via XPC). +/// Keys: @"fileId", @"filename", @"parentId", @"isDirectory", @"size", @"modDate", +/// @"isUploaded", @"isDownloaded", @"isDownloading", @"isUploading", @"childItemCount" +- (instancetype)initWithDictionary:(NSDictionary *)dict; + +/// Convenience initializer with explicit parameters. +- (instancetype)initWithIdentifier:(NSString *)fileId + filename:(NSString *)name + parentIdentifier:(NSFileProviderItemIdentifier)parentId + isDirectory:(BOOL)isDir + size:(int64_t)size + modDate:(nullable NSDate *)date; + +/// Returns a placeholder root container item. ++ (instancetype)rootContainerItem; + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/extensions/fileprovider/FileProviderItem.mm b/src/extensions/fileprovider/FileProviderItem.mm new file mode 100644 index 0000000000..842ed60af6 --- /dev/null +++ b/src/extensions/fileprovider/FileProviderItem.mm @@ -0,0 +1,238 @@ +// FileProviderItem -- NSFileProviderItem adapter implementation. +// Maps sync journal metadata to the NSFileProviderItem protocol for Finder integration. + +#import "FileProviderItem.h" + +#import +#import + +static os_log_t itemLog(void) { + static os_log_t log = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + log = os_log_create("eu.opencloud.desktop.fileprovider", "item"); + }); + return log; +} + +#pragma mark - UTI Helper + +/// Derives a UTI from a filename extension. Returns "public.data" as fallback for files, +/// "public.folder" for directories. +static NSString *utiForFilename(NSString *filename, BOOL isDirectory) { + if (isDirectory) { + return UTTypeFolder.identifier; + } + + NSString *extension = filename.pathExtension; + if (extension.length > 0) { + UTType *type = [UTType typeWithFilenameExtension:extension]; + if (type != nil) { + return type.identifier; + } + } + + return UTTypeData.identifier; +} + +#pragma mark - FileProviderItem + +API_AVAILABLE(macos(12.0)) +@implementation FileProviderItem { + NSFileProviderItemIdentifier _itemIdentifier; + NSFileProviderItemIdentifier _parentItemIdentifier; + NSString *_filename; + NSString *_typeIdentifier; + BOOL _isDirectory; + NSNumber *_documentSize; + NSDate *_contentModificationDate; + NSDate *_creationDate; + BOOL _isUploaded; + BOOL _isDownloaded; + BOOL _isDownloading; + BOOL _isUploading; + NSNumber *_childItemCount; + NSFileProviderItemVersion *_itemVersion; +} + +#pragma mark - Initializers + +- (instancetype)initWithIdentifier:(NSString *)fileId + filename:(NSString *)name + parentIdentifier:(NSFileProviderItemIdentifier)parentId + isDirectory:(BOOL)isDir + size:(int64_t)size + modDate:(nullable NSDate *)date { + self = [super init]; + if (self) { + _itemIdentifier = [fileId copy]; + _parentItemIdentifier = [parentId copy]; + _filename = [name copy]; + _isDirectory = isDir; + _typeIdentifier = utiForFilename(name, isDir); + _documentSize = isDir ? nil : @(size); + _contentModificationDate = date; + _creationDate = date; + _isUploaded = YES; + _isDownloaded = NO; + _isDownloading = NO; + _isUploading = NO; + _childItemCount = nil; + + // Build itemVersion from modification date (or a static seed if no date). + // NSFileProviderItemVersion is required for replicated extensions. + NSData *versionData; + if (date) { + int64_t epoch = (int64_t)[date timeIntervalSince1970]; + versionData = [NSData dataWithBytes:&epoch length:sizeof(epoch)]; + } else { + uint64_t seed = 1; + versionData = [NSData dataWithBytes:&seed length:sizeof(seed)]; + } + _itemVersion = [[NSFileProviderItemVersion alloc] initWithContentVersion:versionData + metadataVersion:versionData]; + + os_log_debug(itemLog(), "Created FileProviderItem id=%{public}@ name=%{public}@ dir=%d", + fileId, name, isDir); + } + return self; +} + +- (instancetype)initWithDictionary:(NSDictionary *)dict { + NSString *fileId = dict[@"fileId"] ?: @""; + // Accept both "filename" (old XPC format) and "name" (shared plist format). + NSString *filename = dict[@"filename"] ?: dict[@"name"] ?: @""; + NSString *parentId = dict[@"parentId"] ?: NSFileProviderRootContainerItemIdentifier; + BOOL isDirectory = [dict[@"isDirectory"] boolValue]; + int64_t size = [dict[@"size"] longLongValue]; + + // Accept both NSDate "modDate" (old XPC format) and NSNumber "modtime" (shared plist, seconds since epoch). + NSDate *modDate = dict[@"modDate"]; + if (!modDate && dict[@"modtime"]) { + NSTimeInterval seconds = [dict[@"modtime"] doubleValue]; + if (seconds > 0) { + modDate = [NSDate dateWithTimeIntervalSince1970:seconds]; + } + } + + self = [self initWithIdentifier:fileId + filename:filename + parentIdentifier:parentId + isDirectory:isDirectory + size:size + modDate:modDate]; + if (self) { + // Override transfer state from dictionary if present + if (dict[@"isUploaded"] != nil) { + _isUploaded = [dict[@"isUploaded"] boolValue]; + } + if (dict[@"isDownloaded"] != nil) { + _isDownloaded = [dict[@"isDownloaded"] boolValue]; + } + if (dict[@"isDownloading"] != nil) { + _isDownloading = [dict[@"isDownloading"] boolValue]; + } + if (dict[@"isUploading"] != nil) { + _isUploading = [dict[@"isUploading"] boolValue]; + } + if (dict[@"childItemCount"] != nil) { + _childItemCount = dict[@"childItemCount"]; + } + } + return self; +} + ++ (instancetype)rootContainerItem { + FileProviderItem *root = [[FileProviderItem alloc] + initWithIdentifier:NSFileProviderRootContainerItemIdentifier + filename:@"OpenCloud" + parentIdentifier:NSFileProviderRootContainerItemIdentifier + isDirectory:YES + size:0 + modDate:nil]; + return root; +} + +#pragma mark - NSFileProviderItem Properties + +- (NSFileProviderItemIdentifier)itemIdentifier { + return _itemIdentifier; +} + +- (NSFileProviderItemIdentifier)parentItemIdentifier { + return _parentItemIdentifier; +} + +- (NSString *)filename { + return _filename; +} + +- (NSString *)typeIdentifier { + return _typeIdentifier; +} + +- (NSFileProviderItemCapabilities)capabilities { + if (_isDirectory) { + return NSFileProviderItemCapabilitiesAllowsAll; + } + + return NSFileProviderItemCapabilitiesAllowsReading + | NSFileProviderItemCapabilitiesAllowsWriting + | NSFileProviderItemCapabilitiesAllowsRenaming + | NSFileProviderItemCapabilitiesAllowsDeleting + | NSFileProviderItemCapabilitiesAllowsEvicting; +} + +- (NSNumber *)documentSize { + return _documentSize; +} + +- (NSDate *)contentModificationDate { + return _contentModificationDate; +} + +- (NSDate *)creationDate { + return _creationDate; +} + +- (BOOL)isUploaded { + return _isUploaded; +} + +- (BOOL)isDownloaded { + return _isDownloaded; +} + +- (BOOL)isDownloading { + return _isDownloading; +} + +- (BOOL)isUploading { + return _isUploading; +} + +- (NSNumber *)childItemCount { + return _childItemCount; +} + +- (NSFileProviderItemVersion *)itemVersion { + return _itemVersion; +} + +- (UTType *)contentType { + if (_isDirectory) { + return UTTypeFolder; + } + + NSString *extension = _filename.pathExtension; + if (extension.length > 0) { + UTType *type = [UTType typeWithFilenameExtension:extension]; + if (type != nil) { + return type; + } + } + + return UTTypeData; +} + +@end diff --git a/src/extensions/fileprovider/FileProviderThumbnails.h b/src/extensions/fileprovider/FileProviderThumbnails.h new file mode 100644 index 0000000000..de12175363 --- /dev/null +++ b/src/extensions/fileprovider/FileProviderThumbnails.h @@ -0,0 +1,35 @@ +// FileProviderThumbnails -- Helper class for fetching and caching thumbnails +// served to the macOS File Provider framework via NSFileProviderThumbnailing. +#pragma once + +#import +#import + +@class FileProviderXPCService; + +NS_ASSUME_NONNULL_BEGIN + +/// Fetches and caches file thumbnails for the File Provider extension. +/// Uses XPC to request thumbnail data from the main app, and maintains +/// a two-tier cache (NSCache in-memory + disk in the app group container) +/// with a 24-hour TTL. +API_AVAILABLE(macos(12.0)) +@interface FileProviderThumbnails : NSObject + +/// Designated initializer. +/// @param xpcService The XPC service used to request thumbnails from the main app. +- (instancetype)initWithXPCService:(FileProviderXPCService *)xpcService; + +/// Fetch a thumbnail for a given file identifier. +/// @param fileId The server-side file identifier. +/// @param size The requested thumbnail dimensions. +/// @param handler Called with thumbnail image data (PNG) or nil if unavailable. +/// Error is non-nil only on infrastructure failures, not missing thumbnails. +- (void)fetchThumbnail:(NSString *)fileId size:(CGSize)size completionHandler:(void (^)(NSData *_Nullable imageData, NSError *_Nullable error))handler; + +/// Remove all cached thumbnails (both in-memory and on-disk). +- (void)clearCache; + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/extensions/fileprovider/FileProviderThumbnails.mm b/src/extensions/fileprovider/FileProviderThumbnails.mm new file mode 100644 index 0000000000..2282048ba2 --- /dev/null +++ b/src/extensions/fileprovider/FileProviderThumbnails.mm @@ -0,0 +1,204 @@ +// FileProviderThumbnails -- Thumbnail fetching and caching for the File Provider extension. +// Two-tier cache: NSCache (in-memory) + disk cache in the app group container. + +#import "FileProviderThumbnails.h" +#import "FileProviderXPCService.h" + +#import + +static os_log_t thumbnailLog(void) { + static os_log_t log = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + log = os_log_create("eu.opencloud.desktop.fileprovider", "thumbnails"); + }); + return log; +} + +/// Cache TTL: 24 hours in seconds. +static const NSTimeInterval kThumbnailCacheTTL = 24.0 * 60.0 * 60.0; + +/// Maximum number of thumbnails kept in the in-memory cache. +static const NSUInteger kMemoryCacheCountLimit = 200; + +#pragma mark - FileProviderThumbnails + +API_AVAILABLE(macos(12.0)) +@implementation FileProviderThumbnails { + FileProviderXPCService *_xpcService; + + /// In-memory cache keyed by "fileId-WxH". + NSCache *_memoryCache; + + /// Serial queue protecting disk cache reads/writes. + dispatch_queue_t _cacheQueue; + + /// Root directory for the on-disk thumbnail cache inside the app group container. + NSURL *_diskCacheURL; +} + +- (instancetype)initWithXPCService:(FileProviderXPCService *)xpcService { + self = [super init]; + if (self) { + _xpcService = xpcService; + + _memoryCache = [[NSCache alloc] init]; + _memoryCache.countLimit = kMemoryCacheCountLimit; + + _cacheQueue = dispatch_queue_create("eu.opencloud.desktop.fileprovider.thumbnailcache", + DISPATCH_QUEUE_SERIAL); + + // Use the app group container for shared disk cache. + NSURL *groupContainer = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:@"group.eu.opencloud.desktop"]; + if (groupContainer) { + _diskCacheURL = [groupContainer URLByAppendingPathComponent:@"ThumbnailCache" + isDirectory:YES]; + } else { + // Fallback to temporary directory if app group is unavailable. + os_log_error(thumbnailLog(), "App group container unavailable, using temp dir for thumbnail cache"); + _diskCacheURL = [NSURL fileURLWithPath:[NSTemporaryDirectory() + stringByAppendingPathComponent:@"OpenCloudThumbnailCache"] + isDirectory:YES]; + } + + // Ensure the cache directory exists. + [[NSFileManager defaultManager] createDirectoryAtURL:_diskCacheURL + withIntermediateDirectories:YES + attributes:nil + error:nil]; + } + return self; +} + +#pragma mark - Public + +- (void)fetchThumbnail:(NSString *)fileId + size:(CGSize)size + completionHandler:(void (^)(NSData * _Nullable, NSError * _Nullable))handler { + + NSString *cacheKey = [self _cacheKeyForFileId:fileId size:size]; + + // 1. Check in-memory cache. + NSData *memoryCached = [_memoryCache objectForKey:cacheKey]; + if (memoryCached) { + os_log_debug(thumbnailLog(), "Thumbnail cache hit (memory) for %{public}@", fileId); + handler(memoryCached, nil); + return; + } + + // 2. Check disk cache (off main thread). + dispatch_async(_cacheQueue, ^{ + NSData *diskCached = [self _readDiskCacheForKey:cacheKey]; + if (diskCached) { + os_log_debug(thumbnailLog(), "Thumbnail cache hit (disk) for %{public}@", fileId); + [self->_memoryCache setObject:diskCached forKey:cacheKey]; + handler(diskCached, nil); + return; + } + + // 3. Fetch via XPC from the main app. + os_log_info(thumbnailLog(), "Fetching thumbnail via XPC for %{public}@ size=%.0fx%.0f", + fileId, size.width, size.height); + + id proxy = self->_xpcService.remoteObjectProxy; + if (!proxy) { + os_log_error(thumbnailLog(), "No XPC proxy for thumbnail fetch of %{public}@", fileId); + handler(nil, nil); + return; + } + + [proxy fetchThumbnail:fileId size:size completionHandler:^(NSData *imageData, NSError *error) { + if (error) { + os_log_error(thumbnailLog(), "XPC thumbnail error for %{public}@: %{public}@", + fileId, error.localizedDescription); + handler(nil, error); + return; + } + + if (!imageData || imageData.length == 0) { + // No thumbnail available -- graceful degradation. + os_log_debug(thumbnailLog(), "No thumbnail available for %{public}@", fileId); + handler(nil, nil); + return; + } + + // Cache the result. + [self->_memoryCache setObject:imageData forKey:cacheKey]; + dispatch_async(self->_cacheQueue, ^{ + [self _writeDiskCache:imageData forKey:cacheKey]; + }); + + os_log_info(thumbnailLog(), "Thumbnail fetched and cached for %{public}@ (%lu bytes)", + fileId, (unsigned long)imageData.length); + handler(imageData, nil); + }]; + }); +} + +- (void)clearCache { + [_memoryCache removeAllObjects]; + + dispatch_async(_cacheQueue, ^{ + NSError *error = nil; + [[NSFileManager defaultManager] removeItemAtURL:self->_diskCacheURL error:&error]; + if (error) { + os_log_error(thumbnailLog(), "Failed to clear disk cache: %{public}@", + error.localizedDescription); + } + [[NSFileManager defaultManager] createDirectoryAtURL:self->_diskCacheURL + withIntermediateDirectories:YES + attributes:nil + error:nil]; + os_log_info(thumbnailLog(), "Thumbnail cache cleared"); + }); +} + +#pragma mark - Private: Cache Key + +- (NSString *)_cacheKeyForFileId:(NSString *)fileId size:(CGSize)size { + return [NSString stringWithFormat:@"%@-%.0fx%.0f", fileId, size.width, size.height]; +} + +#pragma mark - Private: Disk Cache + +/// Returns the file URL for a given cache key inside the disk cache directory. +- (NSURL *)_diskCacheFileURLForKey:(NSString *)key { + // Use a simple hash to avoid filesystem-unfriendly characters. + NSString *safeKey = [[key dataUsingEncoding:NSUTF8StringEncoding] base64EncodedStringWithOptions:0]; + return [_diskCacheURL URLByAppendingPathComponent:safeKey]; +} + +/// Reads data from disk cache if it exists and has not expired (24h TTL). +/// Must be called on _cacheQueue. +- (NSData * _Nullable)_readDiskCacheForKey:(NSString *)key { + NSURL *fileURL = [self _diskCacheFileURLForKey:key]; + + NSError *error = nil; + NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:fileURL.path error:&error]; + if (!attrs) { + return nil; + } + + // Check TTL. + NSDate *modDate = attrs[NSFileModificationDate]; + if (modDate && [[NSDate date] timeIntervalSinceDate:modDate] > kThumbnailCacheTTL) { + // Expired -- remove stale entry. + [[NSFileManager defaultManager] removeItemAtURL:fileURL error:nil]; + return nil; + } + + return [NSData dataWithContentsOfURL:fileURL options:0 error:&error]; +} + +/// Writes data to disk cache. Must be called on _cacheQueue. +- (void)_writeDiskCache:(NSData *)data forKey:(NSString *)key { + NSURL *fileURL = [self _diskCacheFileURLForKey:key]; + NSError *error = nil; + if (![data writeToURL:fileURL options:NSDataWritingAtomic error:&error]) { + os_log_error(thumbnailLog(), "Failed to write thumbnail to disk cache: %{public}@", + error.localizedDescription); + } +} + +@end diff --git a/src/extensions/fileprovider/FileProviderXPCService.h b/src/extensions/fileprovider/FileProviderXPCService.h new file mode 100644 index 0000000000..f00a61fd98 --- /dev/null +++ b/src/extensions/fileprovider/FileProviderXPCService.h @@ -0,0 +1,140 @@ +// FileProviderXPCService -- XPC communication bridge between the File Provider +// extension process and the main OpenCloud application process. +#pragma once + +#import +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Stable XPC service name shared between the extension and the main app. +/// Both sides must agree on this identifier. +static NSString *const kOpenCloudXPCServiceName = @"eu.opencloud.desktop.fileprovider.xpc"; + +/// App Group identifier used to share data between the main app and extension. +static NSString *const kOpenCloudAppGroupIdentifier = @"P4D766R5ZA.eu.opencloud.desktop"; + +/// Filename for the XPC listener endpoint stored in the App Group shared container. +/// The main app writes this file; the extension reads it to establish the XPC connection. +static NSString *const kOpenCloudXPCEndpointFilename = @"xpc_listener_endpoint.data"; + +#pragma mark - XPC Protocol + +/// Protocol defining the messages the File Provider Extension sends to the main +/// OpenCloud application via XPC. The main app must vend an object conforming +/// to this protocol on its NSXPCListener. +/// +/// All methods are asynchronous and use completion handlers to return results +/// back to the extension process. +@protocol OpenCloudXPCServiceProtocol + +/// Request the main app to hydrate (download) a file's contents. +/// @param fileId The server-side file identifier. +/// @param url The local URL where the content should be written. +/// @param handler Called when hydration completes; error is nil on success. +- (void)requestHydration:(NSString *)fileId targetURL:(NSURL *)url completionHandler:(void (^)(NSError *_Nullable error))handler; + +/// Schedule an upload of a locally-created or modified file to the server. +/// @param localURL The local file URL containing the content to upload. +/// @param parentId The server-side identifier of the parent folder. +/// @param handler Called with the server-assigned file ID on success, or error. +- (void)scheduleUpload:(NSURL *)localURL + parentIdentifier:(NSString *)parentId + completionHandler:(void (^)(NSString *_Nullable serverFileId, NSError *_Nullable error))handler; + +/// Query the current pin state for a file. +/// @param fileId The server-side file identifier. +/// @param handler Called with the pin state (as NSInteger) or error. +- (void)requestPinState:(NSString *)fileId completionHandler:(void (^)(NSInteger pinState, NSError *_Nullable error))handler; + +/// Set the pin state for a file (e.g., always keep downloaded, or free space). +/// @param pinState The desired pin state (as NSInteger). +/// @param fileId The server-side file identifier. +/// @param handler Called when the operation completes; error is nil on success. +- (void)setPinState:(NSInteger)pinState forFileId:(NSString *)fileId completionHandler:(void (^)(NSError *_Nullable error))handler; + +/// Connectivity check. Returns YES if the main app is alive and responding. +/// @param handler Called with the liveness status. +- (void)ping:(void (^)(BOOL alive))handler; + +/// Enumerate child items of a container (folder) from the sync journal. +/// @param containerId The file ID of the parent container, or root identifier. +/// @param cursor Opaque pagination cursor (empty string for first page). +/// @param handler Called with an array of item dictionaries, an optional next cursor +/// (nil if no more pages), or an error. +- (void)enumerateItems:(NSString *)containerId + cursor:(NSString *)cursor + completionHandler:(void (^)(NSArray *_Nullable items, NSString *_Nullable nextCursor, NSError *_Nullable error))handler; + +/// Fetch metadata for a single item by its server-side file identifier. +/// @param identifier The file ID to look up. +/// @param handler Called with item metadata dictionary or error. +- (void)itemForIdentifier:(NSString *)identifier completionHandler:(void (^)(NSDictionary *_Nullable itemDict, NSError *_Nullable error))handler; + +/// Create a directory on the server. +/// @param name The directory name. +/// @param parentId The server-side identifier of the parent folder. +/// @param handler Called with metadata dictionary of the created directory, or error. +- (void)createDirectory:(NSString *)name + parentIdentifier:(NSString *)parentId + completionHandler:(void (^)(NSDictionary *_Nullable itemDict, NSError *_Nullable error))handler; + +/// Rename an item on the server. +/// @param fileId The server-side file identifier. +/// @param newName The new filename. +/// @param handler Called with updated metadata dictionary, or error. +- (void)renameItem:(NSString *)fileId + newName:(NSString *)newName + completionHandler:(void (^)(NSDictionary *_Nullable itemDict, NSError *_Nullable error))handler; + +/// Move an item to a different parent folder on the server. +/// @param fileId The server-side file identifier. +/// @param newParentId The server-side identifier of the new parent folder. +/// @param handler Called with updated metadata dictionary, or error. +- (void)moveItem:(NSString *)fileId + newParent:(NSString *)newParentId + completionHandler:(void (^)(NSDictionary *_Nullable itemDict, NSError *_Nullable error))handler; + +/// Delete an item from the server. +/// @param fileId The server-side file identifier. +/// @param handler Called with nil on success, or error. +- (void)deleteItem:(NSString *)fileId completionHandler:(void (^)(NSError *_Nullable error))handler; + +/// Fetch a thumbnail image for a file. +/// @param fileId The server-side file identifier. +/// @param size The requested thumbnail dimensions. +/// @param handler Called with PNG image data, or nil if no thumbnail is available. +- (void)fetchThumbnail:(NSString *)fileId size:(CGSize)size completionHandler:(void (^)(NSData *_Nullable imageData, NSError *_Nullable error))handler; + +@end + +#pragma mark - XPC Service Source + +/// Implements NSFileProviderServiceSource to provide XPC connectivity between +/// the File Provider extension and the main OpenCloud app. +/// +/// The extension registers this service source so the system can broker +/// connections. The main app's NSXPCListener vends an object conforming to +/// OpenCloudXPCServiceProtocol. +API_AVAILABLE(macos(12.0)) +@interface FileProviderXPCService : NSObject + +/// The stable service name used to identify this XPC service. +@property (nonatomic, readonly, copy) NSFileProviderServiceName serviceName; + +/// Returns a proxy object conforming to OpenCloudXPCServiceProtocol for +/// sending messages to the main application. May return nil if the +/// connection has not been established. +@property (nonatomic, readonly, nullable) id remoteObjectProxy; + +/// Designated initializer. +- (instancetype)init; + +/// Explicitly invalidate the XPC connection. Called during extension teardown. +- (void)invalidate; + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/extensions/fileprovider/FileProviderXPCService.mm b/src/extensions/fileprovider/FileProviderXPCService.mm new file mode 100644 index 0000000000..7fc2b5b199 --- /dev/null +++ b/src/extensions/fileprovider/FileProviderXPCService.mm @@ -0,0 +1,183 @@ +// FileProviderXPCService -- XPC communication bridge implementation. +// Manages the NSXPCConnection lifecycle between extension and main app. + +#import "FileProviderXPCService.h" + +static os_log_t xpcLog(void) { + static os_log_t log = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + log = os_log_create("eu.opencloud.desktop.fileprovider", "xpc"); + }); + return log; +} + +/// Maximum number of automatic reconnection attempts before giving up. +static const NSUInteger MAX_RECONNECT_ATTEMPTS = 3; + +/// Delay between reconnection attempts (in seconds). +static const NSTimeInterval RECONNECT_DELAY = 2.0; + +#pragma mark - FileProviderXPCService + +API_AVAILABLE(macos(12.0)) +@implementation FileProviderXPCService { + NSXPCConnection *_connection; + NSUInteger _reconnectAttempts; + BOOL _invalidated; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _reconnectAttempts = 0; + _invalidated = NO; + // Connection is established lazily on first remoteObjectProxy call. + // Enumeration uses the shared plist and does not need XPC. + } + return self; +} + +#pragma mark - NSFileProviderServiceSource + +- (NSFileProviderServiceName)serviceName { + return kOpenCloudXPCServiceName; +} + +- (nullable NSXPCListenerEndpoint *)makeListenerEndpointAndReturnError:(NSError *__autoreleasing *)error { + // This method is called by the system when the main app wants to connect + // to this extension's service. For the extension-to-app direction, we + // use the connection created in _establishConnection instead. + // + // Return nil here; the actual communication channel is set up via + // NSXPCConnection to the main app's Mach service. + os_log_info(xpcLog(), "makeListenerEndpointAndReturnError called"); + + if (error) { + *error = [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: @"Service endpoint not available from extension side"}]; + } + return nil; +} + +#pragma mark - Connection Management + +- (void)_establishConnection { + if (_invalidated) { + os_log_info(xpcLog(), "Connection not established: service has been invalidated"); + return; + } + + // Read the listener endpoint from the App Group shared container. + // The main app writes this file when it starts its anonymous NSXPCListener. + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) { + os_log_error(xpcLog(), "Cannot access App Group container: %{public}@", kOpenCloudAppGroupIdentifier); + [self _handleConnectionFailure]; + return; + } + + NSURL *endpointURL = [containerURL URLByAppendingPathComponent:kOpenCloudXPCEndpointFilename]; + NSData *endpointData = [NSData dataWithContentsOfURL:endpointURL]; + if (!endpointData) { + os_log_error(xpcLog(), "XPC endpoint file not found at: %{public}@ (main app may not be running)", + endpointURL.path); + [self _handleConnectionFailure]; + return; + } + + NSError *unarchiveError = nil; + NSXPCListenerEndpoint *endpoint = [NSKeyedUnarchiver unarchivedObjectOfClass:[NSXPCListenerEndpoint class] + fromData:endpointData + error:&unarchiveError]; + if (!endpoint || unarchiveError) { + os_log_error(xpcLog(), "Failed to unarchive XPC endpoint: %{public}@", + unarchiveError.localizedDescription); + [self _handleConnectionFailure]; + return; + } + + os_log_info(xpcLog(), "Read XPC listener endpoint from App Group container"); + + _connection = [[NSXPCConnection alloc] initWithListenerEndpoint:endpoint]; + + // Configure the remote interface (what we expect the main app to implement). + _connection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(OpenCloudXPCServiceProtocol)]; + + __weak __typeof__(self) weakSelf = self; + + _connection.interruptionHandler = ^{ + os_log_error(xpcLog(), "XPC connection interrupted"); + [weakSelf _handleConnectionFailure]; + }; + + _connection.invalidationHandler = ^{ + os_log_error(xpcLog(), "XPC connection invalidated"); + [weakSelf _handleConnectionFailure]; + }; + + [_connection resume]; + _reconnectAttempts = 0; + + os_log_info(xpcLog(), "XPC connection established via App Group endpoint"); +} + +- (void)_handleConnectionFailure { + if (_invalidated) { + return; + } + + _connection = nil; + + if (_reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + os_log_error(xpcLog(), "Max reconnection attempts (%lu) reached, giving up", (unsigned long)MAX_RECONNECT_ATTEMPTS); + return; + } + + _reconnectAttempts++; + os_log_info(xpcLog(), "Scheduling reconnection attempt %lu/%lu in %.0f seconds", + (unsigned long)_reconnectAttempts, + (unsigned long)MAX_RECONNECT_ATTEMPTS, + RECONNECT_DELAY); + + __weak __typeof__(self) weakSelf = self; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(RECONNECT_DELAY * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + [weakSelf _establishConnection]; + }); +} + +#pragma mark - Remote Object Access + +- (nullable id)remoteObjectProxy { + if (!_connection) { + // Lazily establish the connection on first use. + os_log_info(xpcLog(), "remoteObjectProxy: establishing connection on demand"); + [self _establishConnection]; + } + + if (!_connection) { + os_log_error(xpcLog(), "remoteObjectProxy: no connection available after attempt"); + return nil; + } + + return (id)[_connection remoteObjectProxyWithErrorHandler:^(NSError *error) { + os_log_error(xpcLog(), "Remote object proxy error: %{public}@", error.localizedDescription); + }]; +} + +#pragma mark - Teardown + +- (void)invalidate { + os_log_info(xpcLog(), "Invalidating XPC service"); + _invalidated = YES; + + if (_connection) { + [_connection invalidate]; + _connection = nil; + } +} + +@end diff --git a/src/extensions/fileprovider/Info.plist.in b/src/extensions/fileprovider/Info.plist.in new file mode 100644 index 0000000000..c4cc724488 --- /dev/null +++ b/src/extensions/fileprovider/Info.plist.in @@ -0,0 +1,39 @@ + + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + OpenCloud File Provider + CFBundleExecutable + OpenCloudFileProviderExtension + CFBundleIdentifier + ${MACOSX_BUNDLE_GUI_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundlePackageType + XPC! + CFBundleShortVersionString + ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundleVersion + ${MACOSX_BUNDLE_BUNDLE_VERSION} + NSExtension + + NSExtensionFileProviderDocumentGroup + P4D766R5ZA.eu.opencloud.desktop + NSExtensionFileProviderSupportsEnumeration + + NSExtensionPointIdentifier + com.apple.fileprovider-nonui + NSExtensionPrincipalClass + OpenCloudFileProviderExtension + + + diff --git a/src/extensions/fileprovider/OpenCloudFileProvider.entitlements b/src/extensions/fileprovider/OpenCloudFileProvider.entitlements new file mode 100644 index 0000000000..e3d7aa8577 --- /dev/null +++ b/src/extensions/fileprovider/OpenCloudFileProvider.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + P4D766R5ZA.eu.opencloud.desktop + + com.apple.security.network.client + + + diff --git a/src/extensions/fileprovider/OpenCloudFileProvider.entitlements.in b/src/extensions/fileprovider/OpenCloudFileProvider.entitlements.in new file mode 100644 index 0000000000..dc0aed2f49 --- /dev/null +++ b/src/extensions/fileprovider/OpenCloudFileProvider.entitlements.in @@ -0,0 +1,20 @@ + + + + + + com.apple.security.application-groups + + 283S23Y8TH.eu.opencloud.desktop + + com.apple.developer.fileprovider.server-capability + + keychain-access-groups + + $(AppIdentifierPrefix)eu.opencloud.desktop + + + diff --git a/src/extensions/fileprovider/OpenCloudFileProviderExtension.h b/src/extensions/fileprovider/OpenCloudFileProviderExtension.h new file mode 100644 index 0000000000..6e88dfc7b7 --- /dev/null +++ b/src/extensions/fileprovider/OpenCloudFileProviderExtension.h @@ -0,0 +1,22 @@ +// OpenCloudFileProviderExtension -- NSFileProviderReplicatedExtension implementation +// for macOS Files On Demand. Runs in an isolated extension process. +#pragma once + +#import +#import +#import + +/// The principal class for the OpenCloud File Provider App Extension. +/// Implements NSFileProviderReplicatedExtension (and NSFileProviderEnumerating) +/// to integrate with the macOS Files On Demand system. +/// +/// All protocol methods are currently stubbed and return appropriate +/// "not implemented" errors while calling their completion handlers +/// to prevent deadlocks. +API_AVAILABLE(macos(12.0)) +@interface OpenCloudFileProviderExtension : NSObject + +/// The file provider domain this extension instance serves. +@property (nonatomic, readonly, strong) NSFileProviderDomain *domain; + +@end diff --git a/src/extensions/fileprovider/OpenCloudFileProviderExtension.mm b/src/extensions/fileprovider/OpenCloudFileProviderExtension.mm new file mode 100644 index 0000000000..14f2cb30ec --- /dev/null +++ b/src/extensions/fileprovider/OpenCloudFileProviderExtension.mm @@ -0,0 +1,944 @@ +// OpenCloudFileProviderExtension -- NSFileProviderReplicatedExtension implementation. +// Runs as a separate process managed by the macOS File Provider framework. + +#import "OpenCloudFileProviderExtension.h" + +#import "FileProviderEnumerator.h" +#import "FileProviderItem.h" +#import "FileProviderThumbnails.h" +#import "FileProviderXPCService.h" + +static os_log_t extensionLog(void) { + static os_log_t log = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + log = os_log_create("eu.opencloud.desktop.fileprovider", "extension"); + }); + return log; +} + +/// Appends a trace line to the debug log file in the App Group container. +static NSString *traceLogPath(void) { + // Try App Group container first + NSURL *container = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (container) { + return [[container URLByAppendingPathComponent:@"fp_debug.log"] path]; + } + // Fallback to sandbox temp dir + return [NSTemporaryDirectory() stringByAppendingPathComponent:@"fp_debug.log"]; +} + +static void appendTrace(NSString *line) { + NSString *path = traceLogPath(); + NSFileHandle *fh = [NSFileHandle fileHandleForWritingAtPath:path]; + if (!fh) { + [[NSFileManager defaultManager] createFileAtPath:path contents:nil attributes:nil]; + fh = [NSFileHandle fileHandleForWritingAtPath:path]; + } + if (fh) { + [fh seekToEndOfFile]; + [fh writeData:[line dataUsingEncoding:NSUTF8StringEncoding]]; + [fh closeFile]; + } +} + +/// Creates an NSError in the file provider extension domain for "not implemented" stubs. +static NSError *notImplementedError(void) { + return [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: @"Not yet implemented"}]; +} + +/// Creates an NSError indicating the user is not authenticated. +static NSError *notAuthenticatedError(void) { + return [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorNotAuthenticated + userInfo:@{NSLocalizedDescriptionKey: + NSLocalizedString(@"Bitte melde dich in der OpenCloud App an, um auf deine Dateien zugreifen zu können.", + @"FileProvider auth error")}]; +} + +/// Creates a user-visible error for configuration/connectivity issues. +static NSError *configUnavailableError(NSString *detail) { + NSString *message = [NSString stringWithFormat: + NSLocalizedString(@"Die OpenCloud App muss gestartet und angemeldet sein, um Dateien herunterladen zu können. (%@)", + @"FileProvider config error"), detail]; + return [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: message}]; +} + +// Keep old name as alias for backward compatibility in non-download code paths. +static NSError *xpcUnavailableError(void) { + return configUnavailableError(@"Hintergrund-Synchronisation nicht verfügbar"); +} + +#pragma mark - Default Item Capabilities + +/// Returns default NSFileProviderItemCapabilities for items served by this extension. +static NSFileProviderItemCapabilities defaultItemCapabilities(void) { + return NSFileProviderItemCapabilitiesAllowsReading + | NSFileProviderItemCapabilitiesAllowsWriting + | NSFileProviderItemCapabilitiesAllowsRenaming + | NSFileProviderItemCapabilitiesAllowsReparenting + | NSFileProviderItemCapabilitiesAllowsTrashing + | NSFileProviderItemCapabilitiesAllowsDeleting; +} + +#pragma mark - OpenCloudFileProviderExtension + +API_AVAILABLE(macos(12.0)) +@implementation OpenCloudFileProviderExtension { + NSFileProviderDomain *_domain; + FileProviderXPCService *_xpcService; + FileProviderThumbnails *_thumbnails; + + /// Serialisation queue for hydration coalescing state. + dispatch_queue_t _hydrationQueue; + + /// Maps file identifiers to arrays of pending completion handlers for in-flight + /// hydration requests. When a hydration is already in progress for a given fileId, + /// subsequent requests queue their handlers here instead of issuing a second XPC call. + NSMutableDictionary *_pendingHydrations; +} + +#pragma mark - Lifecycle + +- (instancetype)initWithDomain:(NSFileProviderDomain *)domain { + self = [super init]; + if (self) { + _domain = domain; + // Multiple trace mechanisms to diagnose + NSLog(@">>> EXTENSION INIT domain=%@", domain.identifier); + os_log_fault(extensionLog(), ">>> EXTENSION INIT domain=%{public}@", domain.identifier); + + // Try writing to a KNOWN writable location + NSString *homeDir = NSHomeDirectory(); + NSString *tracePath = [homeDir stringByAppendingPathComponent:@"fp_debug.log"]; + NSString *initLine = [NSString stringWithFormat:@"INIT domain=%@ home=%@\n", domain.identifier, homeDir]; + [initLine writeToFile:tracePath atomically:YES encoding:NSUTF8StringEncoding error:nil]; + + appendTrace([NSString stringWithFormat:@"[%@] EXTENSION INIT domain=%@\n", + [NSDate date], domain.identifier]); + _xpcService = [[FileProviderXPCService alloc] init]; + _hydrationQueue = dispatch_queue_create("eu.opencloud.desktop.fileprovider.hydration", + DISPATCH_QUEUE_SERIAL); + _pendingHydrations = [[NSMutableDictionary alloc] init]; + _thumbnails = [[FileProviderThumbnails alloc] initWithXPCService:_xpcService]; + os_log_info(extensionLog(), "Extension initialized for domain: %{public}@", domain.identifier); + } + return self; +} + +- (void)invalidate { + os_log_info(extensionLog(), "Extension invalidated for domain: %{public}@", _domain.identifier); + [_xpcService invalidate]; + _xpcService = nil; +} + +#pragma mark - NSFileProviderReplicatedExtension (Item Lookup) + +- (NSProgress *)itemForIdentifier:(NSFileProviderItemIdentifier)identifier + request:(NSFileProviderRequest *)request + completionHandler:(void (^)(NSFileProviderItem _Nullable, NSError * _Nullable))completionHandler { + os_log_info(extensionLog(), "itemForIdentifier: %{public}@", identifier); + + // Root container and trash are always resolvable without data. + if ([identifier isEqualToString:NSFileProviderRootContainerItemIdentifier]) { + completionHandler([FileProviderItem rootContainerItem], nil); + return [NSProgress discreteProgressWithTotalUnitCount:0]; + } + if ([identifier isEqualToString:NSFileProviderTrashContainerItemIdentifier]) { + FileProviderItem *trashItem = [[FileProviderItem alloc] + initWithIdentifier:NSFileProviderTrashContainerItemIdentifier + filename:@".Trash" + parentIdentifier:NSFileProviderRootContainerItemIdentifier + isDirectory:YES + size:0 + modDate:nil]; + completionHandler(trashItem, nil); + return [NSProgress discreteProgressWithTotalUnitCount:0]; + } + + // Look up the item from the shared metadata plist in the App Group container. + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (containerURL) { + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; + NSData *data = [NSData dataWithContentsOfURL:metadataURL]; + if (data) { + NSArray *items = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListImmutable + format:nil + error:nil]; + for (NSDictionary *dict in items) { + if ([dict[@"fileId"] isEqualToString:identifier]) { + FileProviderItem *item = [[FileProviderItem alloc] initWithDictionary:dict]; + os_log_info(extensionLog(), "itemForIdentifier: found %{public}@ in shared metadata", identifier); + completionHandler(item, nil); + return [NSProgress discreteProgressWithTotalUnitCount:0]; + } + } + } + } + + os_log_error(extensionLog(), "itemForIdentifier: %{public}@ not found in shared metadata", identifier); + completionHandler(nil, [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorNoSuchItem + userInfo:@{NSLocalizedDescriptionKey: @"Item not found"}]); + return [NSProgress discreteProgressWithTotalUnitCount:0]; +} + +#pragma mark - NSFileProviderReplicatedExtension (Content Fetch) + +- (NSProgress *)fetchContentsForItemWithIdentifier:(NSFileProviderItemIdentifier)itemIdentifier + version:(NSFileProviderItemVersion *)requestedVersion + request:(NSFileProviderRequest *)request + completionHandler:(void (^)(NSURL * _Nullable, NSFileProviderItem _Nullable, NSError * _Nullable))completionHandler { + os_log_info(extensionLog(), "fetchContents: hydration requested for %{public}@", itemIdentifier); + + NSProgress *progress = [NSProgress discreteProgressWithTotalUnitCount:100]; + + // Note: NSFileProviderRequest.isCancelled not available pre-macOS 15; skip check. + + NSString *fileId = [itemIdentifier copy]; + + // Coalesce concurrent hydration requests for the same identifier. + dispatch_async(_hydrationQueue, ^{ + NSMutableArray *existingHandlers = self->_pendingHydrations[fileId]; + if (existingHandlers != nil) { + // A hydration for this fileId is already in flight — queue up. + os_log_info(extensionLog(), "fetchContents: coalescing hydration for %{public}@", fileId); + [existingHandlers addObject:[completionHandler copy]]; + return; + } + + // First request for this fileId — start the hydration. + self->_pendingHydrations[fileId] = [NSMutableArray arrayWithObject:[completionHandler copy]]; + + // --- Direct download: read config from App Group container --- + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) { + os_log_error(extensionLog(), "fetchContents: cannot access App Group container"); + [self _completeHydrationForFileId:fileId url:nil item:nil + error:configUnavailableError(@"App-Container nicht verfügbar")]; + return; + } + + // Read server config (davUrl + accessToken). + NSURL *configURL = [containerURL URLByAppendingPathComponent:@"fileprovider_config.plist"]; + NSData *configData = [NSData dataWithContentsOfURL:configURL]; + if (!configData) { + os_log_error(extensionLog(), "fetchContents: config plist not found at %{public}@", configURL.path); + [self _completeHydrationForFileId:fileId url:nil item:nil + error:configUnavailableError(@"Server-Konfiguration nicht gefunden")]; + return; + } + NSDictionary *config = [NSPropertyListSerialization propertyListWithData:configData + options:NSPropertyListImmutable + format:nil error:nil]; + NSString *davUrl = config[@"davUrl"]; + NSString *accessToken = config[@"accessToken"]; + if (!davUrl || davUrl.length == 0) { + os_log_error(extensionLog(), "fetchContents: no davUrl in config"); + [self _completeHydrationForFileId:fileId url:nil item:nil + error:configUnavailableError(@"Server-URL nicht konfiguriert")]; + return; + } + if (!accessToken || accessToken.length == 0) { + os_log_error(extensionLog(), "fetchContents: no access token — app may still be starting"); + // Use a transient error so fileproviderd retries later instead of + // removing the item from Finder (which NotAuthenticated would do). + [self _completeHydrationForFileId:fileId url:nil item:nil + error:configUnavailableError(@"Anmeldung wird vorbereitet — bitte kurz warten")]; + return; + } + + // Look up the file path from the items plist. + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; + NSData *metaData = [NSData dataWithContentsOfURL:metadataURL]; + NSString *filePath = nil; + NSDictionary *itemDict = nil; + if (metaData) { + NSArray *items = [NSPropertyListSerialization propertyListWithData:metaData + options:NSPropertyListImmutable + format:nil error:nil]; + for (NSDictionary *item in items) { + if ([item[@"fileId"] isEqualToString:fileId]) { + filePath = item[@"path"]; + itemDict = item; + break; + } + } + } + if (!filePath) { + os_log_error(extensionLog(), "fetchContents: fileId %{public}@ not found in items plist", fileId); + [self _completeHydrationForFileId:fileId url:nil item:nil + error:[NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorNoSuchItem + userInfo:@{NSLocalizedDescriptionKey: + NSLocalizedString(@"Diese Datei wurde nicht gefunden. Möglicherweise wurde sie verschoben oder gelöscht.", + @"FileProvider item not found")}]]; + return; + } + + // Build the WebDAV download URL: davUrl + "/" + filePath + NSString *davBase = [davUrl hasSuffix:@"/"] ? [davUrl substringToIndex:davUrl.length - 1] : davUrl; + NSString *encodedPath = [filePath stringByAddingPercentEncodingWithAllowedCharacters: + [NSCharacterSet URLPathAllowedCharacterSet]]; + NSString *downloadURLString = [NSString stringWithFormat:@"%@/%@", davBase, encodedPath]; + NSURL *downloadURL = [NSURL URLWithString:downloadURLString]; + + os_log_info(extensionLog(), "fetchContents: downloading %{public}@ from %{public}@", + fileId, downloadURLString); + + // Create temp file URL. + NSString *tempDir = NSTemporaryDirectory(); + NSString *tempFilename = [NSString stringWithFormat:@"hydration-%@", [[NSUUID UUID] UUIDString]]; + NSURL *tempURL = [NSURL fileURLWithPath:[tempDir stringByAppendingPathComponent:tempFilename]]; + + // Use NSURLSession to download the file directly. + NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:downloadURL]; + [req setValue:[NSString stringWithFormat:@"Bearer %@", accessToken] + forHTTPHeaderField:@"Authorization"]; + + NSURLSessionConfiguration *sessionConfig = [NSURLSessionConfiguration defaultSessionConfiguration]; + NSURLSession *session = [NSURLSession sessionWithConfiguration:sessionConfig]; + + NSDictionary *capturedItemDict = itemDict; + + // Use the file size from metadata to drive the progress indicator. + int64_t expectedSize = [itemDict[@"size"] longLongValue]; + if (expectedSize > 0) { + progress.totalUnitCount = expectedSize; + } + + NSURLSessionDownloadTask *downloadTask = [session downloadTaskWithRequest:req completionHandler:^(NSURL *location, NSURLResponse *response, NSError *error) { + if (error) { + os_log_error(extensionLog(), "fetchContents: download failed for %{public}@: %{public}@", + fileId, error.localizedDescription); + [self _completeHydrationForFileId:fileId url:nil item:nil error:error]; + return; + } + + NSHTTPURLResponse *http = (NSHTTPURLResponse *)response; + if (http.statusCode < 200 || http.statusCode >= 300) { + os_log_error(extensionLog(), "fetchContents: HTTP %ld for %{public}@", (long)http.statusCode, fileId); + NSFileProviderErrorCode fpCode; + NSString *userMessage; + if (http.statusCode == 401 || http.statusCode == 403) { + fpCode = NSFileProviderErrorNotAuthenticated; + userMessage = NSLocalizedString( + @"Die Anmeldung ist abgelaufen. Bitte melde dich in der OpenCloud App erneut an.", + @"FileProvider HTTP 401/403"); + } else if (http.statusCode == 404) { + fpCode = NSFileProviderErrorNoSuchItem; + userMessage = NSLocalizedString( + @"Diese Datei wurde auf dem Server nicht gefunden. Sie wurde möglicherweise gelöscht oder verschoben.", + @"FileProvider HTTP 404"); + [self _removeStaleItemFromPlist:fileId]; + } else if (http.statusCode >= 500) { + fpCode = NSFileProviderErrorServerUnreachable; + userMessage = [NSString stringWithFormat: + NSLocalizedString(@"Der Server hat einen Fehler gemeldet (HTTP %ld). Bitte versuche es später erneut.", + @"FileProvider HTTP 5xx"), (long)http.statusCode]; + } else { + fpCode = NSFileProviderErrorServerUnreachable; + userMessage = [NSString stringWithFormat: + NSLocalizedString(@"Die Datei konnte nicht heruntergeladen werden (HTTP %ld).", + @"FileProvider HTTP error"), (long)http.statusCode]; + } + NSError *httpError = [NSError errorWithDomain:NSFileProviderErrorDomain + code:fpCode + userInfo:@{NSLocalizedDescriptionKey: userMessage}]; + [self _completeHydrationForFileId:fileId url:nil item:nil error:httpError]; + return; + } + + // Move downloaded file to our temp path. + NSError *moveError = nil; + [[NSFileManager defaultManager] moveItemAtURL:location toURL:tempURL error:&moveError]; + if (moveError) { + os_log_error(extensionLog(), "fetchContents: move failed: %{public}@", moveError.localizedDescription); + [self _completeHydrationForFileId:fileId url:nil item:nil error:moveError]; + return; + } + + os_log_info(extensionLog(), "fetchContents: download succeeded for %{public}@", fileId); + progress.completedUnitCount = progress.totalUnitCount; + + // Build the item from the shared plist metadata, then mark it as + // downloaded so fileproviderd knows the content is now available + // locally and does not retry or show an error badge. + NSMutableDictionary *itemDict = capturedItemDict + ? [capturedItemDict mutableCopy] + : nil; + if (itemDict) { + itemDict[@"isDownloaded"] = @YES; + } + FileProviderItem *item = itemDict + ? [[FileProviderItem alloc] initWithDictionary:itemDict] + : nil; + [self _completeHydrationForFileId:fileId url:tempURL item:item error:nil]; + }]; + + // Add the download task's built-in progress as a child of the progress + // we return to fileproviderd, so Finder shows a real download indicator. + [progress addChild:downloadTask.progress withPendingUnitCount:progress.totalUnitCount]; + + [downloadTask resume]; + }); + + return progress; +} + +/// Dispatches all queued completion handlers for a given fileId and removes the +/// entry from the pending-hydrations map. Must be called on any queue -- it +/// internally hops to _hydrationQueue for thread safety. +- (void)_completeHydrationForFileId:(NSString *)fileId + url:(NSURL *)url + item:(NSFileProviderItem)item + error:(NSError *)error { + dispatch_async(_hydrationQueue, ^{ + NSArray *handlers = [self->_pendingHydrations[fileId] copy]; + [self->_pendingHydrations removeObjectForKey:fileId]; + + // Call handlers outside the queue to avoid blocking it. + dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + for (void (^handler)(NSURL *, NSFileProviderItem, NSError *) in handlers) { + handler(url, item, error); + } + }); + }); +} + +/// Removes a stale item (HTTP 404) from the shared fileprovider_items.plist +/// so subsequent enumerations no longer surface it to Finder. +- (void)_removeStaleItemFromPlist:(NSString *)fileId { + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) return; + + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; + NSData *data = [NSData dataWithContentsOfURL:metadataURL]; + if (!data) return; + + NSArray *items = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListMutableContainers + format:nil + error:nil]; + if (![items isKindOfClass:[NSArray class]]) return; + + NSMutableArray *mutableItems = [items mutableCopy]; + NSUInteger indexToRemove = NSNotFound; + for (NSUInteger i = 0; i < mutableItems.count; i++) { + NSDictionary *item = mutableItems[i]; + if ([item[@"fileId"] isEqualToString:fileId]) { + indexToRemove = i; + break; + } + } + + if (indexToRemove != NSNotFound) { + NSString *path = mutableItems[indexToRemove][@"path"]; + [mutableItems removeObjectAtIndex:indexToRemove]; + + NSData *newData = [NSPropertyListSerialization dataWithPropertyList:mutableItems + format:NSPropertyListBinaryFormat_v1_0 + options:0 + error:nil]; + if (newData) { + [newData writeToURL:metadataURL atomically:YES]; + os_log_info(extensionLog(), "Removed stale item %{public}@ (%{public}@) from shared plist", fileId, path); + } + } +} + +#pragma mark - NSFileProviderReplicatedExtension (Create) + +- (NSProgress *)createItemBasedOnTemplate:(id)itemTemplate + fields:(NSFileProviderItemFields)fields + contents:(NSURL *)url + options:(NSFileProviderCreateItemOptions)options + request:(NSFileProviderRequest *)request + completionHandler:(void (^)(NSFileProviderItem _Nullable, + NSFileProviderItemFields, + BOOL, + NSError * _Nullable))completionHandler { + os_log_info(extensionLog(), "createItem: %{public}@ parent=%{public}@", + itemTemplate.filename, itemTemplate.parentItemIdentifier); + + NSProgress *progress = [NSProgress discreteProgressWithTotalUnitCount:100]; + + // When fileproviderd imports items from disk (e.g. after a DB reset), it calls + // createItem for directories/files it found on FPFS. Look up the item in the + // shared plist — if found, return it directly without needing XPC. + { + NSString *templateName = itemTemplate.filename; + NSString *templateParent = itemTemplate.parentItemIdentifier; + + appendTrace([NSString stringWithFormat:@"[%@] createItem: name=%@ parent=%@ options=%lu\n", + [NSDate date], templateName, templateParent, (unsigned long)options]); + + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (containerURL) { + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; + NSData *data = [NSData dataWithContentsOfURL:metadataURL]; + if (data) { + NSArray *items = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListImmutable + format:nil + error:nil]; + for (NSDictionary *dict in items) { + NSString *parentId = dict[@"parentId"] ?: NSFileProviderRootContainerItemIdentifier; + NSString *filename = dict[@"filename"] ?: dict[@"name"] ?: @""; + if ([filename isEqualToString:templateName] + && [parentId isEqualToString:templateParent]) { + FileProviderItem *item = [[FileProviderItem alloc] initWithDictionary:dict]; + os_log_fault(extensionLog(), "createItem: PLIST MATCH %{public}@ id=%{public}@", + filename, item.itemIdentifier); + completionHandler(item, NSFileProviderItemFields(0), NO, nil); + return progress; + } + } + + appendTrace([NSString stringWithFormat: + @"[%@] createItem: NO MATCH name=%@ parent=%@ plistCount=%lu options=%lu\n", + [NSDate date], templateName, templateParent, (unsigned long)items.count, (unsigned long)options]); + + // For reconciliation imports (MayAlreadyExist), the item is stale FPFS + // data from a previous session that's no longer in our metadata. + // Return NSFileProviderErrorNoSuchItem so fileproviderd removes it + // from FPFS and continues reconciliation without a server-unreachable stall. + if (options & NSFileProviderCreateItemMayAlreadyExist) { + completionHandler(nil, 0, NO, + [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorNoSuchItem + userInfo:@{NSLocalizedDescriptionKey: @"Item not in local metadata"}]); + return progress; + } + } + } + } + + NSString *parentId = [itemTemplate.parentItemIdentifier copy]; + + // --- Direct WebDAV upload (no XPC needed) --- + { + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) { + completionHandler(nil, 0, NO, configUnavailableError(@"App-Container nicht verfügbar")); + return progress; + } + + // Read config + NSURL *configURL = [containerURL URLByAppendingPathComponent:@"fileprovider_config.plist"]; + NSData *configData = [NSData dataWithContentsOfURL:configURL]; + NSDictionary *config = configData + ? [NSPropertyListSerialization propertyListWithData:configData options:NSPropertyListImmutable format:nil error:nil] + : nil; + NSString *davUrl = config[@"davUrl"]; + NSString *accessToken = config[@"accessToken"]; + + if (!davUrl || davUrl.length == 0 || !accessToken || accessToken.length == 0) { + completionHandler(nil, 0, NO, configUnavailableError(@"Server-Konfiguration oder Anmeldung fehlt")); + return progress; + } + + // Resolve parent path from items plist + NSString *parentPath = @""; + if (![parentId isEqualToString:NSFileProviderRootContainerItemIdentifier]) { + NSURL *metaURL = [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; + NSData *metaData = [NSData dataWithContentsOfURL:metaURL]; + if (metaData) { + NSArray *items = [NSPropertyListSerialization propertyListWithData:metaData + options:NSPropertyListImmutable format:nil error:nil]; + for (NSDictionary *item in items) { + if ([item[@"fileId"] isEqualToString:parentId]) { + parentPath = item[@"path"] ?: @""; + break; + } + } + } + } + + NSString *filename = itemTemplate.filename; + NSString *davBase = [davUrl hasSuffix:@"/"] ? [davUrl substringToIndex:davUrl.length - 1] : davUrl; + + if (url == nil) { + // --- Directory creation via MKCOL --- + NSString *dirPath = parentPath.length > 0 + ? [NSString stringWithFormat:@"%@/%@", parentPath, filename] + : filename; + NSString *encodedPath = [dirPath stringByAddingPercentEncodingWithAllowedCharacters: + [NSCharacterSet URLPathAllowedCharacterSet]]; + NSString *mkcolURLString = [NSString stringWithFormat:@"%@/%@", davBase, encodedPath]; + NSURL *mkcolURL = [NSURL URLWithString:mkcolURLString]; + + os_log_info(extensionLog(), "createItem: MKCOL %{public}@", mkcolURLString); + + NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:mkcolURL]; + req.HTTPMethod = @"MKCOL"; + [req setValue:[NSString stringWithFormat:@"Bearer %@", accessToken] forHTTPHeaderField:@"Authorization"]; + + NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]]; + [[session dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + if (error) { + os_log_error(extensionLog(), "createItem: MKCOL failed: %{public}@", error.localizedDescription); + completionHandler(nil, 0, NO, error); + return; + } + NSHTTPURLResponse *http = (NSHTTPURLResponse *)response; + if (http.statusCode < 200 || http.statusCode >= 300) { + os_log_error(extensionLog(), "createItem: MKCOL HTTP %ld", (long)http.statusCode); + completionHandler(nil, 0, NO, [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Ordner konnte nicht erstellt werden (HTTP %ld)", (long)http.statusCode]}]); + return; + } + + NSString *newFileId = [http.allHeaderFields[@"OC-FileId"] copy] + ?: [NSString stringWithFormat:@"%@!%@", parentId, [[NSUUID UUID] UUIDString]]; + + FileProviderItem *createdItem = [[FileProviderItem alloc] + initWithIdentifier:newFileId filename:filename parentIdentifier:parentId + isDirectory:YES size:0 modDate:[NSDate date]]; + os_log_info(extensionLog(), "createItem: directory created id=%{public}@", newFileId); + progress.completedUnitCount = 100; + completionHandler(createdItem, NSFileProviderItemFields(0), NO, nil); + }] resume]; + + } else { + // --- File upload via PUT --- + NSString *filePath = parentPath.length > 0 + ? [NSString stringWithFormat:@"%@/%@", parentPath, filename] + : filename; + NSString *encodedPath = [filePath stringByAddingPercentEncodingWithAllowedCharacters: + [NSCharacterSet URLPathAllowedCharacterSet]]; + NSString *putURLString = [NSString stringWithFormat:@"%@/%@", davBase, encodedPath]; + NSURL *putURL = [NSURL URLWithString:putURLString]; + + os_log_info(extensionLog(), "createItem: PUT %{public}@", putURLString); + + NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:putURL]; + req.HTTPMethod = @"PUT"; + [req setValue:[NSString stringWithFormat:@"Bearer %@", accessToken] forHTTPHeaderField:@"Authorization"]; + + NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]]; + [[session uploadTaskWithRequest:req fromFile:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + if (error) { + os_log_error(extensionLog(), "createItem: PUT failed: %{public}@", error.localizedDescription); + completionHandler(nil, 0, NO, error); + return; + } + NSHTTPURLResponse *http = (NSHTTPURLResponse *)response; + if (http.statusCode < 200 || http.statusCode >= 300) { + os_log_error(extensionLog(), "createItem: PUT HTTP %ld", (long)http.statusCode); + completionHandler(nil, 0, NO, [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Datei konnte nicht hochgeladen werden (HTTP %ld)", (long)http.statusCode]}]); + return; + } + + NSString *newFileId = [http.allHeaderFields[@"OC-FileId"] copy] + ?: [NSString stringWithFormat:@"%@!%@", parentId, [[NSUUID UUID] UUIDString]]; + NSDictionary *sizeHeader = http.allHeaderFields; + int64_t fileSize = [sizeHeader[@"Content-Length"] longLongValue]; + if (fileSize == 0) { + // Get size from the uploaded file + NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:url.path error:nil]; + fileSize = [attrs[NSFileSize] longLongValue]; + } + + NSMutableDictionary *itemDict = [@{ + @"fileId": newFileId, + @"filename": filename, + @"path": filePath, + @"parentId": parentId, + @"parentPath": parentPath, + @"isDirectory": @NO, + @"size": @(fileSize), + @"modtime": @((int64_t)[[NSDate date] timeIntervalSince1970]), + @"etag": [http.allHeaderFields[@"ETag"] copy] ?: @"", + @"isVirtualFile": @NO, + @"isDownloaded": @YES, + } mutableCopy]; + + FileProviderItem *createdItem = [[FileProviderItem alloc] initWithDictionary:itemDict]; + os_log_info(extensionLog(), "createItem: uploaded %{public}@ id=%{public}@", filename, newFileId); + progress.completedUnitCount = 100; + completionHandler(createdItem, NSFileProviderItemFields(0), NO, nil); + }] resume]; + } + + return progress; + } + +} + +#pragma mark - NSFileProviderReplicatedExtension (Modify) + +- (NSProgress *)modifyItem:(id)item + baseVersion:(NSFileProviderItemVersion *)version + changedFields:(NSFileProviderItemFields)changedFields + contents:(NSURL *)newContents + options:(NSFileProviderModifyItemOptions)options + request:(NSFileProviderRequest *)request + completionHandler:(void (^)(NSFileProviderItem _Nullable, + NSFileProviderItemFields, + BOOL, + NSError * _Nullable))completionHandler { + os_log_info(extensionLog(), "modifyItem: %{public}@ changedFields=0x%lx", + item.filename, (unsigned long)changedFields); + + NSProgress *progress = [NSProgress discreteProgressWithTotalUnitCount:100]; + + NSString *fileId = [item.itemIdentifier copy]; + + // Check which fields actually require XPC communication with the main app. + const NSFileProviderItemFields criticalFields = + NSFileProviderItemFilename | + NSFileProviderItemParentItemIdentifier | + NSFileProviderItemContents; + + id proxy = _xpcService.remoteObjectProxy; + if (!proxy) { + if (changedFields & criticalFields) { + // Rename, move, or content upload requires XPC — report error. + os_log_error(extensionLog(), "modifyItem: no XPC proxy for critical change 0x%lx on %{public}@", + (unsigned long)changedFields, fileId); + completionHandler(nil, 0, NO, xpcUnavailableError()); + return progress; + } + // Non-critical fields (e.g. lastUsedDate, contentPolicy) — return item + // unchanged so fileproviderd does not mark it as errored. + os_log_info(extensionLog(), "modifyItem: no XPC needed for fields 0x%lx on %{public}@", + (unsigned long)changedFields, fileId); + FileProviderItem *unchanged = [[FileProviderItem alloc] + initWithIdentifier:fileId + filename:item.filename + parentIdentifier:item.parentItemIdentifier + isDirectory:NO + size:[item.documentSize longLongValue] + modDate:item.contentModificationDate]; + progress.completedUnitCount = 100; + completionHandler(unchanged, 0, NO, nil); + return progress; + } + + // Handle rename. + if (changedFields & NSFileProviderItemFilename) { + NSString *newName = [item.filename copy]; + os_log_info(extensionLog(), "modifyItem: renaming %{public}@ to '%{public}@'", fileId, newName); + + [proxy renameItem:fileId newName:newName completionHandler:^(NSDictionary *itemDict, NSError *error) { + if (error) { + os_log_error(extensionLog(), "modifyItem: rename failed: %{public}@", + error.localizedDescription); + completionHandler(nil, 0, NO, error); + return; + } + + FileProviderItem *updatedItem = [[FileProviderItem alloc] initWithDictionary:itemDict]; + os_log_info(extensionLog(), "modifyItem: rename succeeded for %{public}@", fileId); + progress.completedUnitCount = 100; + completionHandler(updatedItem, NSFileProviderItemFields(0), NO, nil); + }]; + return progress; + } + + // Handle re-parent (move). + if (changedFields & NSFileProviderItemParentItemIdentifier) { + NSString *newParentId = [item.parentItemIdentifier copy]; + os_log_info(extensionLog(), "modifyItem: moving %{public}@ to parent %{public}@", fileId, newParentId); + + [proxy moveItem:fileId newParent:newParentId completionHandler:^(NSDictionary *itemDict, NSError *error) { + if (error) { + os_log_error(extensionLog(), "modifyItem: move failed: %{public}@", + error.localizedDescription); + completionHandler(nil, 0, NO, error); + return; + } + + FileProviderItem *updatedItem = [[FileProviderItem alloc] initWithDictionary:itemDict]; + os_log_info(extensionLog(), "modifyItem: move succeeded for %{public}@", fileId); + progress.completedUnitCount = 100; + completionHandler(updatedItem, NSFileProviderItemFields(0), NO, nil); + }]; + return progress; + } + + // Handle content update (re-upload). + if (changedFields & NSFileProviderItemContents) { + if (!newContents) { + os_log_error(extensionLog(), "modifyItem: content change flagged but no content URL for %{public}@", + fileId); + completionHandler(nil, 0, NO, [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorNoSuchItem + userInfo:@{NSLocalizedDescriptionKey: @"Content URL missing for content update"}]); + return progress; + } + + NSString *parentId = [item.parentItemIdentifier copy]; + + // Stage the content for upload. + NSString *stagingDir = NSTemporaryDirectory(); + NSString *stagingFilename = [NSString stringWithFormat:@"reupload-%@-%@", + fileId, [[NSUUID UUID] UUIDString]]; + NSURL *stagingURL = [NSURL fileURLWithPath:[stagingDir stringByAppendingPathComponent:stagingFilename]]; + + NSError *copyError = nil; + [[NSFileManager defaultManager] copyItemAtURL:newContents toURL:stagingURL error:©Error]; + if (copyError) { + os_log_error(extensionLog(), "modifyItem: failed to stage content: %{public}@", + copyError.localizedDescription); + completionHandler(nil, 0, NO, copyError); + return progress; + } + + os_log_info(extensionLog(), "modifyItem: re-uploading content for %{public}@", fileId); + + [proxy scheduleUpload:stagingURL parentIdentifier:parentId completionHandler:^(NSString *serverFileId, NSError *error) { + [[NSFileManager defaultManager] removeItemAtURL:stagingURL error:nil]; + + if (error) { + os_log_error(extensionLog(), "modifyItem: re-upload failed: %{public}@", + error.localizedDescription); + completionHandler(nil, 0, NO, error); + return; + } + + os_log_info(extensionLog(), "modifyItem: re-upload succeeded for %{public}@", fileId); + + FileProviderItem *updatedItem = [[FileProviderItem alloc] + initWithIdentifier:serverFileId ?: fileId + filename:item.filename + parentIdentifier:parentId + isDirectory:NO + size:[item.documentSize longLongValue] + modDate:[NSDate date]]; + progress.completedUnitCount = 100; + completionHandler(updatedItem, NSFileProviderItemFields(0), NO, nil); + }]; + return progress; + } + + // No recognized field changes — return the item unchanged. + os_log_info(extensionLog(), "modifyItem: no actionable field changes for %{public}@", fileId); + FileProviderItem *unchangedItem = [[FileProviderItem alloc] + initWithIdentifier:fileId + filename:item.filename + parentIdentifier:item.parentItemIdentifier + isDirectory:NO + size:[item.documentSize longLongValue] + modDate:item.contentModificationDate]; + completionHandler(unchangedItem, NSFileProviderItemFields(0), NO, nil); + return progress; +} + +#pragma mark - NSFileProviderReplicatedExtension (Delete) + +- (NSProgress *)deleteItemWithIdentifier:(NSFileProviderItemIdentifier)identifier + baseVersion:(NSFileProviderItemVersion *)version + options:(NSFileProviderDeleteItemOptions)options + request:(NSFileProviderRequest *)request + completionHandler:(void (^)(NSError * _Nullable))completionHandler { + os_log_info(extensionLog(), "deleteItem: %{public}@", identifier); + + NSProgress *progress = [NSProgress discreteProgressWithTotalUnitCount:1]; + + id proxy = _xpcService.remoteObjectProxy; + if (!proxy) { + os_log_error(extensionLog(), "deleteItem: no XPC proxy available"); + completionHandler(xpcUnavailableError()); + return progress; + } + + NSString *fileId = [identifier copy]; + + [proxy deleteItem:fileId completionHandler:^(NSError *error) { + if (error) { + os_log_error(extensionLog(), "deleteItem: failed for %{public}@: %{public}@", + fileId, error.localizedDescription); + completionHandler(error); + return; + } + + os_log_info(extensionLog(), "deleteItem: succeeded for %{public}@", fileId); + progress.completedUnitCount = 1; + completionHandler(nil); + }]; + + return progress; +} + +#pragma mark - NSFileProviderEnumerating + +- (id)enumeratorForContainerItemIdentifier:(NSFileProviderItemIdentifier)containerItemIdentifier + request:(NSFileProviderRequest *)request + error:(NSError *__autoreleasing *)error { + os_log_info(extensionLog(), "enumeratorForContainerItemIdentifier: %{public}@", containerItemIdentifier); + + // The root container, folder identifiers, and the working set all use the + // same enumerator class. The enumerator fetches items via XPC for the given container. + if ([containerItemIdentifier isEqualToString:NSFileProviderRootContainerItemIdentifier] + || [containerItemIdentifier isEqualToString:NSFileProviderWorkingSetContainerItemIdentifier] + || containerItemIdentifier.length > 0) { + + FileProviderEnumerator *enumerator = + [[FileProviderEnumerator alloc] initWithContainerIdentifier:containerItemIdentifier + xpcService:_xpcService]; + return enumerator; + } + + os_log_error(extensionLog(), "enumeratorForContainerItemIdentifier: unsupported container %{public}@", + containerItemIdentifier); + if (error) { + *error = [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorNoSuchItem + userInfo:@{NSLocalizedDescriptionKey: @"Unsupported container identifier"}]; + } + return nil; +} + +#pragma mark - NSFileProviderThumbnailing + +- (NSProgress *)fetchThumbnailsForItemIdentifiers:(NSArray *)itemIdentifiers + requestedSize:(CGSize)size + perThumbnailCompletionHandler:(void (^)(NSFileProviderItemIdentifier, + NSData * _Nullable, + NSError * _Nullable))perThumbnailHandler + completionHandler:(void (^)(NSError * _Nullable))completionHandler { + os_log_info(extensionLog(), "fetchThumbnails: requested for %lu items at %.0fx%.0f", + (unsigned long)itemIdentifiers.count, size.width, size.height); + + NSProgress *progress = [NSProgress progressWithTotalUnitCount:(int64_t)itemIdentifiers.count]; + + dispatch_group_t group = dispatch_group_create(); + + for (NSFileProviderItemIdentifier identifier in itemIdentifiers) { + dispatch_group_enter(group); + + [_thumbnails fetchThumbnail:identifier size:size completionHandler:^(NSData *imageData, NSError *error) { + perThumbnailHandler(identifier, imageData, error); + progress.completedUnitCount += 1; + dispatch_group_leave(group); + }]; + } + + dispatch_group_notify(group, dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + os_log_info(extensionLog(), "fetchThumbnails: completed for %lu items", + (unsigned long)itemIdentifiers.count); + completionHandler(nil); + }); + + return progress; +} + +@end diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 6f53259813..ae84043eab 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -207,7 +207,14 @@ else() PROPERTIES MACOSX_PACKAGE_LOCATION Resources ) - set_target_properties(opencloud PROPERTIES OUTPUT_NAME "${APPLICATION_SHORTNAME}" MACOSX_BUNDLE_INFO_PLIST ${CMAKE_CURRENT_SOURCE_DIR}/MacOSXBundleInfo.plist) + set_target_properties(opencloud PROPERTIES + OUTPUT_NAME "${APPLICATION_SHORTNAME}" + MACOSX_BUNDLE_INFO_PLIST ${CMAKE_CURRENT_SOURCE_DIR}/MacOSXBundleInfo.plist + XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_SOURCE_DIR}/src/OpenCloud.entitlements" + XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "$(CODE_SIGN_IDENTITY)" + XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "P4D766R5ZA" + XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME NO + ) endif() install(TARGETS opencloud OpenCloudGui ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) diff --git a/src/libsync/common/syncjournaldb.cpp b/src/libsync/common/syncjournaldb.cpp index 31c1350d53..57f7fc5c26 100644 --- a/src/libsync/common/syncjournaldb.cpp +++ b/src/libsync/common/syncjournaldb.cpp @@ -36,6 +36,10 @@ #include +#ifdef Q_OS_MAC +#import +#endif + using namespace Qt::Literals::StringLiterals; Q_LOGGING_CATEGORY(lcDb, "sync.database", QtInfoMsg) @@ -827,6 +831,10 @@ bool SyncJournalDb::deleteFileRecord(const QString &filename, bool recursively) { QMutexLocker locker(&_mutex); +#ifdef Q_OS_MAC + os_log_fault(OS_LOG_DEFAULT, "deleteFileRecord: %{public}s recursive=%d", qPrintable(filename), recursively); +#endif + if (checkConnect()) { // if (!recursively) { // always delete the actual file. diff --git a/src/libsync/creds/httpcredentials.h b/src/libsync/creds/httpcredentials.h index 48a103a1db..98442f8596 100644 --- a/src/libsync/creds/httpcredentials.h +++ b/src/libsync/creds/httpcredentials.h @@ -64,6 +64,8 @@ class OPENCLOUD_SYNC_EXPORT HttpCredentials : public AbstractCredentials */ bool refreshAccessToken(); + /// Returns the current Bearer access token (empty if not authenticated). + QString accessToken() const { return _accessToken; } protected: HttpCredentials() = default; diff --git a/src/libsync/discovery.cpp b/src/libsync/discovery.cpp index b3da812fa8..77323cfe37 100644 --- a/src/libsync/discovery.cpp +++ b/src/libsync/discovery.cpp @@ -593,9 +593,25 @@ void ProcessDirectoryJob::processFileAnalyzeLocalInfo( }; if (!localEntry.isValid()) { + const bool isNsfpMode = _discoveryData->_syncOptions._vfs->mode() == Vfs::Mode::MacOSNSFileProvider; + const bool isNsfpFile = isNsfpMode && dbEntry.isValid(); + + // NSFP mode: all files (virtual or hydrated) are managed by NSFileProvider, + // not the local filesystem. When the server deletes a file, remove the + // journal record directly — there is no local file for the sync engine + // to delete. The next metadata refresh will update Finder. + if (noServerEntry && isNsfpFile) { + qCInfo(lcDisco) << u"NSFP: server deleted file — removing journal record" << path._original; + _discoveryData->_statedb->deleteFileRecord(path._original, true); + return; + } + + if (isNsfpFile) { + qCInfo(lcDisco) << u"NSFP: preserving file record (no local entry expected)" << path._original; + } if (_queryLocal == ParentNotChanged && dbEntry.isValid()) { // Not modified locally (ParentNotChanged) - if (noServerEntry) { + if (noServerEntry && !isNsfpFile) { // not on the server: Removed on the server, delete locally item->setInstruction(CSYNC_INSTRUCTION_REMOVE); item->_direction = SyncFileItem::Down; @@ -610,8 +626,9 @@ void ProcessDirectoryJob::processFileAnalyzeLocalInfo( qCInfo(lcDisco) << u"Stale DB entry"; _discoveryData->_statedb->deleteFileRecord(path._original, true); return; - } else if (!serverModified) { + } else if (!serverModified && !isNsfpFile) { // Removed locally: also remove on the server. + // In NSFP mode, files are not on the local FS by design — do not treat as removed. if (!dbEntry.serverHasIgnoredFiles()) { item->setInstruction(CSYNC_INSTRUCTION_REMOVE); item->_direction = SyncFileItem::Up; diff --git a/src/libsync/vfs/vfs.cpp b/src/libsync/vfs/vfs.cpp index 7261b2de0c..873189b6f3 100644 --- a/src/libsync/vfs/vfs.cpp +++ b/src/libsync/vfs/vfs.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #ifdef Q_OS_WIN @@ -57,6 +58,8 @@ Optional Vfs::modeFromString(const QString &str) return Mode::WindowsCfApi; } else if (str == QLatin1String("openvfs")) { return Mode::OpenVFS; + } else if (str == QLatin1String("nsfp")) { + return Mode::MacOSNSFileProvider; } return {}; } @@ -73,6 +76,8 @@ QString Utility::enumToString(Vfs::Mode mode) return QStringLiteral("off"); case Vfs::Mode::OpenVFS: return QStringLiteral("openvfs"); + case Vfs::Mode::MacOSNSFileProvider: + return QStringLiteral("nsfp"); } Q_UNREACHABLE(); } @@ -136,9 +141,18 @@ Vfs::Mode OCC::VfsPluginManager::bestAvailableVfsMode() const { if (isVfsPluginAvailable(Vfs::Mode::WindowsCfApi)) { return Vfs::Mode::WindowsCfApi; - } else if (isVfsPluginAvailable(Vfs::Mode::OpenVFS)) { + } +#if defined(Q_OS_MACOS) + if (QOperatingSystemVersion::current() >= QOperatingSystemVersion::MacOSMonterey) { + if (isVfsPluginAvailable(Vfs::Mode::MacOSNSFileProvider)) { + return Vfs::Mode::MacOSNSFileProvider; + } + } +#endif + if (isVfsPluginAvailable(Vfs::Mode::OpenVFS)) { return Vfs::Mode::OpenVFS; - } else if (isVfsPluginAvailable(Vfs::Mode::Off)) { + } + if (isVfsPluginAvailable(Vfs::Mode::Off)) { return Vfs::Mode::Off; } Q_UNREACHABLE(); diff --git a/src/libsync/vfs/vfs.h b/src/libsync/vfs/vfs.h index 3e8208f137..e432646c56 100644 --- a/src/libsync/vfs/vfs.h +++ b/src/libsync/vfs/vfs.h @@ -99,7 +99,7 @@ class OPENCLOUD_SYNC_EXPORT Vfs : public QObject * Currently plugins and modes are one-to-one but that's not required. * The raw integer values are used in Qml */ - enum class Mode : uint8_t { Off = 0, WindowsCfApi = 1, OpenVFS = 2 }; + enum class Mode : uint8_t { Off = 0, WindowsCfApi = 1, OpenVFS = 2, MacOSNSFileProvider = 3 }; Q_ENUM(Mode) enum class ConvertToPlaceholderResult : uint8_t { Ok, Locked }; Q_ENUM(ConvertToPlaceholderResult) diff --git a/src/plugins/vfs/nsfp/CMakeLists.txt b/src/plugins/vfs/nsfp/CMakeLists.txt new file mode 100644 index 0000000000..400185b6a5 --- /dev/null +++ b/src/plugins/vfs/nsfp/CMakeLists.txt @@ -0,0 +1,24 @@ +# CMake build configuration for the macOS NSFileProvider VFS plugin (nsfp). +# Only compiled on Apple platforms (macOS 12+). + +if(APPLE) + add_vfs_plugin(NAME nsfp + SRC + nsfpdomainmanager.mm + nsfpxpchandler.mm + vfs_nsfp.mm + LIBS + "-framework Foundation" + "-framework FileProvider" + "-lsqlite3" + ) + + # Enable ARC for all Objective-C++ sources in this plugin. + target_compile_options(vfs_nsfp PRIVATE -fobjc-arc) + + # The XPC handler needs access to the shared XPC protocol header + # defined in the File Provider extension sources. + target_include_directories(vfs_nsfp PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../../../extensions/fileprovider" + ) +endif() diff --git a/src/plugins/vfs/nsfp/nsfpdomainmanager.h b/src/plugins/vfs/nsfp/nsfpdomainmanager.h new file mode 100644 index 0000000000..b96924ec2e --- /dev/null +++ b/src/plugins/vfs/nsfp/nsfpdomainmanager.h @@ -0,0 +1,85 @@ +// NsfpDomainManager -- manages NSFileProviderDomain lifecycle for the macOS VFS plugin. +#pragma once + +#include + +#include +#include + +#ifdef __OBJC__ +#import +#import +#endif + +namespace OCC { + +/// Callback type for async domain operations. +/// On success, errorMessage is empty. On failure, it contains a description. +using NsfpDomainCompletionHandler = std::function; + +/// Manages the lifecycle of NSFileProviderDomain objects. +/// +/// This is a pure Objective-C++ class (not a QObject) since it interfaces +/// directly with NSFileProvider APIs. All NSFileProvider calls are dispatched +/// on a dedicated serial queue. Results are bridged back to Qt via the +/// completion handler, which callers are expected to invoke on their own +/// thread (e.g. via QMetaObject::invokeMethod with Qt::QueuedConnection). +/// +/// Domain registration is idempotent: if a domain with the given identifier +/// already exists, the manager reconnects to it instead of creating a duplicate. +class NsfpDomainManager +{ +public: + NsfpDomainManager(); + ~NsfpDomainManager(); + + // Non-copyable, non-movable + NsfpDomainManager(const NsfpDomainManager &) = delete; + NsfpDomainManager &operator=(const NsfpDomainManager &) = delete; + + /// Register or reconnect to an NSFileProviderDomain. + /// The identifier must be stable across restarts (account UUID + space ID). + /// The displayName is shown in Finder sidebar. + /// Idempotent: if the domain already exists, reconnects without creating a duplicate. + void addDomain(const QString &identifier, const QString &displayName, NsfpDomainCompletionHandler completionHandler); + + /// Fully remove an NSFileProviderDomain and delete its replica store. + void removeDomain(const QString &identifier, NsfpDomainCompletionHandler completionHandler); + + /// Invalidate the manager for the given domain without removing it. + /// Used during app shutdown so files persist on disk. + void invalidateManager(const QString &identifier); + + /// Signal the File Provider framework to re-enumerate items in the given container. + /// This causes Finder to refresh its view of that directory. + /// @param identifier The domain identifier. + /// @param containerId The container (folder) whose contents changed. + /// Use NSFileProviderRootContainerItemIdentifier for root. + void signalEnumerator(const QString &identifier, const QString &containerId); + + /// Evict (dehydrate) a single item, freeing its local storage. + /// The item must have allowsEviction capability set in its FileProviderItem. + /// @param identifier The domain identifier. + /// @param fileId The NSFileProviderItemIdentifier of the item to evict. + /// @param completionHandler Called with empty string on success, error description on failure. + void evictItem(const QString &identifier, const QString &fileId, NsfpDomainCompletionHandler completionHandler); + + /// Signal the system to perform storage-pressure eviction. + /// The framework will decide which items to evict based on their + /// allowsEviction capability and last-access timestamps. + /// @param identifier The domain identifier. + void requestSystemEviction(const QString &identifier); + +#ifdef __OBJC__ + /// Return a cached NSFileProviderManager for the given domain identifier, + /// creating one via +[NSFileProviderManager managerForDomain:] if needed. + /// Returns nil if the domain has not been registered. + NSFileProviderManager *managerForIdentifier(const QString &identifier); +#endif + +private: + struct Private; + std::unique_ptr _p; +}; + +} // namespace OCC diff --git a/src/plugins/vfs/nsfp/nsfpdomainmanager.mm b/src/plugins/vfs/nsfp/nsfpdomainmanager.mm new file mode 100644 index 0000000000..84f0239669 --- /dev/null +++ b/src/plugins/vfs/nsfp/nsfpdomainmanager.mm @@ -0,0 +1,431 @@ +// NsfpDomainManager implementation -- manages NSFileProviderDomain lifecycle. + +#include "nsfpdomainmanager.h" + +#include +#include +#include + +#import +#import + +Q_LOGGING_CATEGORY(lcNsfpDomainManager, "sync.vfs.nsfp.domain", QtInfoMsg) + +namespace OCC { + +struct NsfpDomainManager::Private +{ + /// Serial dispatch queue for all NSFileProvider calls. + dispatch_queue_t dispatchQueue = dispatch_queue_create("eu.opencloud.vfs.nsfp.domain", DISPATCH_QUEUE_SERIAL); + + /// Thread-safe cache of domain identifier -> NSFileProviderDomain. + QMutex cacheMutex; + QMap domainCache; + QMap managerCache; +}; + +NsfpDomainManager::NsfpDomainManager() + : _p(std::make_unique()) +{ +} + +NsfpDomainManager::~NsfpDomainManager() +{ + // Drain the serial queue so all pending blocks finish before _p is freed. + // Without this, in-flight dispatch_async blocks (e.g. from invalidateManager) + // can access _p->cacheMutex after it has been destroyed → use-after-free. + dispatch_sync(_p->dispatchQueue, ^{}); + + QMutexLocker lock(&_p->cacheMutex); + _p->domainCache.clear(); + _p->managerCache.clear(); +} + +void NsfpDomainManager::addDomain(const QString &identifier, const QString &displayName, + NsfpDomainCompletionHandler completionHandler) +{ + qCInfo(lcNsfpDomainManager) << "addDomain requested:" << identifier << "displayName:" << displayName; + + // Copy parameters by value — they are used in asynchronous blocks + // that outlive this function call. + QString identifierCopy = identifier; + NSString *nsIdentifier = identifier.toNSString(); + NSString *nsDisplayName = displayName.toNSString(); + + // Capture completion handler by value for the block + auto handler = std::move(completionHandler); + + dispatch_async(_p->dispatchQueue, ^{ + // First, check if our domain already exists and is enabled + dispatch_semaphore_t listSemaphore = dispatch_semaphore_create(0); + __block NSError *listError = nil; + __block NSFileProviderDomain *existingDomain = nil; + __block NSMutableArray *staleDomainsToRemove = [NSMutableArray array]; + + [NSFileProviderManager getDomainsWithCompletionHandler:^(NSArray *domains, NSError *error) { + if (error) { + listError = error; + } else { + for (NSFileProviderDomain *domain in domains) { + if ([domain.identifier isEqualToString:nsIdentifier]) { + existingDomain = domain; + } else if ([domain.identifier hasPrefix:@"opencloud"]) { + // Remove stale opencloud domains with different identifiers + [staleDomainsToRemove addObject:domain]; + } + } + } + dispatch_semaphore_signal(listSemaphore); + }]; + dispatch_semaphore_wait(listSemaphore, DISPATCH_TIME_FOREVER); + + if (listError) { + qCWarning(lcNsfpDomainManager) << "Failed to list existing domains:" << QString::fromNSString(listError.localizedDescription); + } + + // If the domain already exists, force-remove it so fileproviderd re-resolves + // the extension UUID from pluginkit on the subsequent addDomain call. + // Reusing the existing domain would leave fileproviderd bound to the old + // extension UUID (e.g. after re-signing the appex), causing ETIMEDOUT on fetch. + if (existingDomain) { + qCInfo(lcNsfpDomainManager) << "Domain already exists, removing for clean re-add:" << identifierCopy + << "userEnabled:" << existingDomain.userEnabled; + dispatch_semaphore_t removeSem = dispatch_semaphore_create(0); + [NSFileProviderManager removeDomain:existingDomain completionHandler:^(NSError *removeErr) { + if (removeErr) { + qCWarning(lcNsfpDomainManager) << "Failed to remove existing domain for re-add:" + << QString::fromNSString(removeErr.localizedDescription); + } else { + qCInfo(lcNsfpDomainManager) << "Existing domain removed — will re-add fresh:" << identifierCopy; + } + dispatch_semaphore_signal(removeSem); + }]; + dispatch_semaphore_wait(removeSem, DISPATCH_TIME_FOREVER); + // Fall through to the addDomain path below so fileproviderd picks up the + // current pluginkit extension UUID. + } + + // Remove stale domains before creating a new one + for (NSFileProviderDomain *staleDomain in staleDomainsToRemove) { + qCInfo(lcNsfpDomainManager) << "Removing stale domain:" + << QString::fromNSString(staleDomain.identifier) + << "userEnabled:" << staleDomain.userEnabled; + + dispatch_semaphore_t removeSem = dispatch_semaphore_create(0); + [NSFileProviderManager removeDomain:staleDomain completionHandler:^(NSError *removeErr) { + if (removeErr) { + qCWarning(lcNsfpDomainManager) << "Failed to remove stale domain:" + << QString::fromNSString(removeErr.localizedDescription); + } else { + qCInfo(lcNsfpDomainManager) << "Stale domain removed successfully:" + << QString::fromNSString(staleDomain.identifier); + } + dispatch_semaphore_signal(removeSem); + }]; + dispatch_semaphore_wait(removeSem, DISPATCH_TIME_FOREVER); + } + + // Create a new domain (only when no existing domain was found) + NSFileProviderDomain *domain = [[NSFileProviderDomain alloc] initWithIdentifier:nsIdentifier + displayName:nsDisplayName]; + + [NSFileProviderManager addDomain:domain completionHandler:^(NSError *error) { + if (error) { + QString errorMsg = QString::fromNSString(error.localizedDescription); + qCWarning(lcNsfpDomainManager) << "Failed to add domain:" << identifierCopy << "error:" << errorMsg; + + // The domain may already be registered in fileproviderd (e.g. addDomain failed + // because getDomainsWithCompletionHandler also failed with -2001 during init, + // so we fell through to the create path even though the domain exists). + // Try to obtain a manager anyway — if the domain is registered, this succeeds + // and we can still call reimportItemsBelowItemWithIdentifier to wake the extension. + NSFileProviderManager *fallbackManager = [NSFileProviderManager managerForDomain:domain]; + if (fallbackManager) { + qCInfo(lcNsfpDomainManager) << "addDomain failed but domain is registered; attempting fallback reimport for:" << identifierCopy; + [fallbackManager reimportItemsBelowItemWithIdentifier:NSFileProviderRootContainerItemIdentifier + completionHandler:^(NSError *reimportErr) { + if (reimportErr) { + qCWarning(lcNsfpDomainManager) << "Fallback reimport failed:" + << QString::fromNSString(reimportErr.localizedDescription); + } else { + qCInfo(lcNsfpDomainManager) << "Fallback reimport succeeded — extension should wake"; + } + }]; + { + QMutexLocker lock(&_p->cacheMutex); + _p->domainCache[identifierCopy] = domain; + _p->managerCache[identifierCopy] = fallbackManager; + } + if (handler) { + handler(QString()); // treat as success — we have a live manager + } + return; + } + + if (handler) { + handler(errorMsg); + } + return; + } + + qCInfo(lcNsfpDomainManager) << "Domain added successfully:" << identifierCopy; + + NSFileProviderManager *manager = [NSFileProviderManager managerForDomain:domain]; + + // Force re-enumeration to clear any backoff state from previous sessions. + [manager reimportItemsBelowItemWithIdentifier:NSFileProviderRootContainerItemIdentifier + completionHandler:^(NSError *reimportErr) { + if (reimportErr) { + qCWarning(lcNsfpDomainManager) << "reimportItems (new domain) failed:" + << QString::fromNSString(reimportErr.localizedDescription); + } else { + qCInfo(lcNsfpDomainManager) << "reimportItems (new domain) succeeded — fileproviderd will re-enumerate"; + } + }]; + + // Check userEnabled status of the newly added domain + qCInfo(lcNsfpDomainManager) << "New domain userEnabled:" << domain.userEnabled; + + if (!domain.userEnabled) { + // Try reconnect on the new domain too + qCInfo(lcNsfpDomainManager) << "New domain is user-disabled, attempting reconnect..."; + [manager reconnectWithCompletionHandler:^(NSError *reconnectError) { + if (reconnectError) { + qCWarning(lcNsfpDomainManager) << "Reconnect on new domain failed:" + << QString::fromNSString(reconnectError.localizedDescription); + } else { + qCInfo(lcNsfpDomainManager) << "Reconnect on new domain succeeded!"; + } + }]; + } + + { + QMutexLocker lock(&_p->cacheMutex); + _p->domainCache[identifierCopy] = domain; + _p->managerCache[identifierCopy] = manager; + } + + if (handler) { + handler(QString()); + } + }]; + }); +} + +void NsfpDomainManager::removeDomain(const QString &identifier, + NsfpDomainCompletionHandler completionHandler) +{ + qCInfo(lcNsfpDomainManager) << "removeDomain requested:" << identifier; + + QString identifierCopy = identifier; + auto handler = std::move(completionHandler); + + dispatch_async(_p->dispatchQueue, ^{ + NSFileProviderDomain *domain = nil; + { + QMutexLocker lock(&_p->cacheMutex); + domain = _p->domainCache.value(identifierCopy, nil); + } + + if (!domain) { + qCWarning(lcNsfpDomainManager) << "removeDomain: domain not found in cache:" << identifierCopy; + if (handler) { + handler(QStringLiteral("Domain not found: %1").arg(identifierCopy)); + } + return; + } + + [NSFileProviderManager removeDomain:domain completionHandler:^(NSError *error) { + if (error) { + QString errorMsg = QString::fromNSString(error.localizedDescription); + qCWarning(lcNsfpDomainManager) << "Failed to remove domain:" << identifierCopy << "error:" << errorMsg; + + if (handler) { + handler(errorMsg); + } + return; + } + + qCInfo(lcNsfpDomainManager) << "Domain removed successfully:" << identifierCopy; + + { + QMutexLocker lock(&_p->cacheMutex); + _p->domainCache.remove(identifierCopy); + _p->managerCache.remove(identifierCopy); + } + + if (handler) { + handler(QString()); + } + }]; + }); +} + +void NsfpDomainManager::invalidateManager(const QString &identifier) +{ + qCInfo(lcNsfpDomainManager) << "invalidateManager requested:" << identifier; + + QString identifierCopy = identifier; + + dispatch_async(_p->dispatchQueue, ^{ + NSFileProviderManager *manager = nil; + { + QMutexLocker lock(&_p->cacheMutex); + manager = _p->managerCache.value(identifierCopy, nil); + } + + if (manager) { + + qCInfo(lcNsfpDomainManager) << "Manager invalidated for domain:" << identifierCopy; + } else { + qCDebug(lcNsfpDomainManager) << "invalidateManager: no manager cached for:" << identifierCopy; + } + + { + QMutexLocker lock(&_p->cacheMutex); + _p->managerCache.remove(identifierCopy); + // Keep the domain in cache so it can be reconnected later + } + }); +} + +NSFileProviderManager *NsfpDomainManager::managerForIdentifier(const QString &identifier) +{ + QMutexLocker lock(&_p->cacheMutex); + + // Return cached manager if available + auto managerIt = _p->managerCache.find(identifier); + if (managerIt != _p->managerCache.end()) { + return managerIt.value(); + } + + // Try to create from cached domain + auto domainIt = _p->domainCache.find(identifier); + if (domainIt != _p->domainCache.end()) { + NSFileProviderManager *manager = [NSFileProviderManager managerForDomain:domainIt.value()]; + _p->managerCache[identifier] = manager; + return manager; + } + + qCDebug(lcNsfpDomainManager) << "managerForIdentifier: no domain registered for:" << identifier; + return nil; +} + +void NsfpDomainManager::signalEnumerator(const QString &identifier, const QString &containerId) +{ + qCInfo(lcNsfpDomainManager) << "signalEnumerator requested for domain:" << identifier + << "container:" << containerId; + + // Copy parameters by value — they must survive past this function's return + // since they are used in asynchronous blocks. + QString identifierCopy = identifier; + QString containerIdCopy = containerId; + NSString *nsContainerId = containerId.toNSString(); + + dispatch_async(_p->dispatchQueue, ^{ + NSFileProviderManager *manager = nil; + { + QMutexLocker lock(&_p->cacheMutex); + manager = _p->managerCache.value(identifierCopy, nil); + } + + if (!manager) { + qCWarning(lcNsfpDomainManager) << "signalEnumerator: no manager for domain:" << identifierCopy; + return; + } + + NSFileProviderItemIdentifier itemId = nsContainerId; + if (containerIdCopy.isEmpty()) { + itemId = NSFileProviderRootContainerItemIdentifier; + } + + [manager signalEnumeratorForContainerItemIdentifier:itemId + completionHandler:^(NSError *error) { + if (error) { + qCWarning(lcNsfpDomainManager) << "signalEnumerator failed:" + << QString::fromNSString(error.localizedDescription); + } else { + qCDebug(lcNsfpDomainManager) << "signalEnumerator succeeded for container:" << containerIdCopy; + } + }]; + }); +} + +void NsfpDomainManager::evictItem(const QString &identifier, const QString &fileId, + NsfpDomainCompletionHandler completionHandler) +{ + qCInfo(lcNsfpDomainManager) << "evictItem requested for domain:" << identifier + << "fileId:" << fileId; + + QString identifierCopy = identifier; + QString fileIdCopy = fileId; + NSString *nsFileId = fileId.toNSString(); + auto handler = std::move(completionHandler); + + dispatch_async(_p->dispatchQueue, ^{ + NSFileProviderManager *manager = nil; + { + QMutexLocker lock(&_p->cacheMutex); + manager = _p->managerCache.value(identifierCopy, nil); + } + + if (!manager) { + qCWarning(lcNsfpDomainManager) << "evictItem: no manager for domain:" << identifierCopy; + if (handler) { + handler(QStringLiteral("No manager for domain: %1").arg(identifierCopy)); + } + return; + } + + [manager evictItemWithIdentifier:nsFileId + completionHandler:^(NSError *error) { + if (error) { + const auto errorMsg = QString::fromNSString(error.localizedDescription); + qCWarning(lcNsfpDomainManager) << "evictItem failed for fileId:" << fileIdCopy + << "error:" << errorMsg; + if (handler) { + handler(errorMsg); + } + } else { + qCInfo(lcNsfpDomainManager) << "evictItem succeeded for fileId:" << fileIdCopy; + if (handler) { + handler(QString()); + } + } + }]; + }); +} + +void NsfpDomainManager::requestSystemEviction(const QString &identifier) +{ + qCInfo(lcNsfpDomainManager) << "requestSystemEviction for domain:" << identifier; + + QString identifierCopy = identifier; + + dispatch_async(_p->dispatchQueue, ^{ + NSFileProviderManager *manager = nil; + { + QMutexLocker lock(&_p->cacheMutex); + manager = _p->managerCache.value(identifierCopy, nil); + } + + if (!manager) { + qCWarning(lcNsfpDomainManager) << "requestSystemEviction: no manager for domain:" << identifierCopy; + return; + } + + // Signal the working set enumerator to let the system decide what to evict + // based on allowsEviction capability and last-access timestamps. + [manager signalEnumeratorForContainerItemIdentifier:NSFileProviderWorkingSetContainerItemIdentifier + completionHandler:^(NSError *error) { + if (error) { + qCWarning(lcNsfpDomainManager) << "requestSystemEviction signal failed:" + << QString::fromNSString(error.localizedDescription); + } else { + qCInfo(lcNsfpDomainManager) << "requestSystemEviction signal sent successfully"; + } + }]; + }); +} + +} // namespace OCC diff --git a/src/plugins/vfs/nsfp/nsfpxpchandler.h b/src/plugins/vfs/nsfp/nsfpxpchandler.h new file mode 100644 index 0000000000..3771047835 --- /dev/null +++ b/src/plugins/vfs/nsfp/nsfpxpchandler.h @@ -0,0 +1,53 @@ +// NsfpXpcHandler -- XPC listener in the main app that handles hydration, +// enumeration, and pin-state requests from the File Provider extension. +#pragma once + +#include +#include + +#include +#include + +#ifdef __OBJC__ +#import +#endif + +namespace OCC { + +class Vfs; + +/// Handles incoming XPC calls from the NSFileProvider extension process. +/// +/// The extension connects to the main app via a Mach-service-based +/// NSXPCListener. This class vends an Objective-C object that conforms to +/// OpenCloudXPCServiceProtocol and forwards requests into the Qt event loop. +/// +/// Thread safety: all SyncJournalDb and HydrationJob work is dispatched to +/// the Qt main thread via QMetaObject::invokeMethod. The XPC listener and +/// its delegate live on a GCD serial queue. +class NsfpXpcHandler : public QObject +{ + Q_OBJECT + +public: + explicit NsfpXpcHandler(Vfs *vfs, QObject *parent = nullptr); + ~NsfpXpcHandler() override; + + // Non-copyable, non-movable + NsfpXpcHandler(const NsfpXpcHandler &) = delete; + NsfpXpcHandler &operator=(const NsfpXpcHandler &) = delete; + + /// Start the NSXPCListener. Must be called after VfsSetupParams are available. + void startListener(); + + /// Stop the listener and abort any in-flight hydration jobs. + void stopListener(); + +private: + struct Private; + std::unique_ptr _p; + + Vfs *_vfs = nullptr; +}; + +} // namespace OCC diff --git a/src/plugins/vfs/nsfp/nsfpxpchandler.mm b/src/plugins/vfs/nsfp/nsfpxpchandler.mm new file mode 100644 index 0000000000..5b003486e3 --- /dev/null +++ b/src/plugins/vfs/nsfp/nsfpxpchandler.mm @@ -0,0 +1,507 @@ +// NsfpXpcHandler implementation -- XPC listener in the main app that handles +// hydration, enumeration, and pin-state requests from the File Provider extension. + +#include "nsfpxpchandler.h" + +#include "common/syncjournaldb.h" +#include "common/syncjournalfilerecord.h" +#include "libsync/vfs/hydrationjob.h" +#include "libsync/vfs/vfs.h" + +#include +#include +#include +#include + +#import + +// Import the shared XPC protocol definition from the extension sources. +// The protocol header is self-contained (no ObjC++ / Qt dependencies). +#import "FileProviderXPCService.h" + +Q_LOGGING_CATEGORY(lcNsfpXpc, "sync.vfs.nsfp.xpc", QtInfoMsg) + +static const int ENUMERATE_PAGE_SIZE = 500; + +using namespace OCC; + +// --------------------------------------------------------------------------- +// Objective-C delegate that conforms to OpenCloudXPCServiceProtocol. +// All heavy lifting is forwarded to the Qt event loop via QPointer + invokeMethod. +// --------------------------------------------------------------------------- + +@interface NsfpXpcDelegate : NSObject +- (instancetype)initWithVfs:(QPointer)vfs handler:(QPointer)handler; +@end + +@implementation NsfpXpcDelegate { + QPointer _vfs; + QPointer _handler; + + /// Guards against duplicate hydration requests for the same fileId. + /// Key: fileId (NSString*). Value: array of pending completion handlers. + NSMutableDictionary *_inflightHydrations; +} + +- (instancetype)initWithVfs:(QPointer)vfs handler:(QPointer)handler { + self = [super init]; + if (self) { + _vfs = vfs; + _handler = handler; + _inflightHydrations = [NSMutableDictionary dictionary]; + } + return self; +} + +#pragma mark - NSXPCListenerDelegate + +- (BOOL)listener:(NSXPCListener *)listener shouldAcceptNewConnection:(NSXPCConnection *)newConnection { + Q_UNUSED(listener) + + qCInfo(lcNsfpXpc) << "Accepting new XPC connection from extension"; + + newConnection.exportedInterface = [NSXPCInterface interfaceWithProtocol:@protocol(OpenCloudXPCServiceProtocol)]; + newConnection.exportedObject = self; + + __weak NSXPCConnection *weakConn = newConnection; + newConnection.invalidationHandler = ^{ + qCInfo(lcNsfpXpc) << "XPC connection invalidated"; + Q_UNUSED(weakConn) + }; + newConnection.interruptionHandler = ^{ + qCInfo(lcNsfpXpc) << "XPC connection interrupted"; + }; + + [newConnection resume]; + return YES; +} + +#pragma mark - OpenCloudXPCServiceProtocol + +- (void)requestHydration:(NSString *)fileId + targetURL:(NSURL *)url + completionHandler:(void (^)(NSError * _Nullable))completionHandler { + + NSString *fileIdCopy = [fileId copy]; + NSURL *urlCopy = [url copy]; + auto handler = [completionHandler copy]; + + qCInfo(lcNsfpXpc) << "requestHydration fileId:" << QString::fromNSString(fileIdCopy) + << "target:" << QString::fromNSString(urlCopy.path); + + // Coalesce: if a hydration for the same fileId is already in flight, queue the callback. + @synchronized (_inflightHydrations) { + NSMutableArray *pending = _inflightHydrations[fileIdCopy]; + if (pending) { + qCInfo(lcNsfpXpc) << "Coalescing hydration request for fileId:" << QString::fromNSString(fileIdCopy); + [pending addObject:handler]; + return; + } + _inflightHydrations[fileIdCopy] = [NSMutableArray arrayWithObject:handler]; + } + + QPointer vfs = _vfs; + __weak __typeof__(self) weakSelf = self; + + QMetaObject::invokeMethod(vfs, [vfs, fileIdCopy, urlCopy, weakSelf]() { + if (!vfs) { + qCWarning(lcNsfpXpc) << "Vfs gone during hydration request"; + [weakSelf completeHydration:fileIdCopy + withError:[NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: @"Sync engine unavailable"}]]; + return; + } + + const auto qFileId = QString::fromNSString(fileIdCopy).toUtf8(); + const auto targetPath = QString::fromNSString(urlCopy.path); + + // Open a QFile as the output device for HydrationJob. + auto device = std::make_unique(targetPath); + + auto *job = new HydrationJob(vfs, qFileId, std::move(device), vfs); + job->setTargetFile(targetPath); + + QObject::connect(job, &HydrationJob::finished, vfs, [weakSelf, fileIdCopy, job]() { + qCInfo(lcNsfpXpc) << "Hydration finished successfully for fileId:" << QString::fromNSString(fileIdCopy); + [weakSelf completeHydration:fileIdCopy withError:nil]; + job->deleteLater(); + }); + QObject::connect(job, &HydrationJob::error, vfs, [weakSelf, fileIdCopy, job](const QString &errorMsg) { + qCWarning(lcNsfpXpc) << "Hydration error for fileId:" << QString::fromNSString(fileIdCopy) << errorMsg; + NSError *nsError = [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: errorMsg.toNSString()}]; + [weakSelf completeHydration:fileIdCopy withError:nsError]; + job->deleteLater(); + }); + + job->start(); + }, Qt::QueuedConnection); +} + +/// Internal helper: resolve all queued completion handlers for a hydration request. +- (void)completeHydration:(NSString *)fileId withError:(NSError * _Nullable)error { + NSArray *handlers = nil; + @synchronized (_inflightHydrations) { + handlers = [_inflightHydrations[fileId] copy]; + [_inflightHydrations removeObjectForKey:fileId]; + } + for (void (^h)(NSError *) in handlers) { + h(error); + } +} + +- (void)scheduleUpload:(NSURL *)localURL + parentIdentifier:(NSString *)parentId + completionHandler:(void (^)(NSString * _Nullable, NSError * _Nullable))completionHandler { + + qCInfo(lcNsfpXpc) << "scheduleUpload — stub, not yet implemented"; + + NSError *error = [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: @"Upload scheduling not yet implemented"}]; + completionHandler(nil, error); +} + +- (void)requestPinState:(NSString *)fileId + completionHandler:(void (^)(NSInteger, NSError * _Nullable))completionHandler { + + NSString *fileIdCopy = [fileId copy]; + QPointer vfs = _vfs; + + QMetaObject::invokeMethod(vfs, [vfs, fileIdCopy, completionHandler]() { + if (!vfs) { + completionHandler(0, [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: @"Sync engine unavailable"}]); + return; + } + + // Look up the record by fileId to get its relative path, then query pinState. + auto *journal = vfs->params().journal; + if (!journal) { + completionHandler(0, [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: @"No journal available"}]); + return; + } + + QString relPath; + const auto qFileId = QString::fromNSString(fileIdCopy).toUtf8(); + journal->getFileRecordsByFileId(qFileId, [&relPath](const SyncJournalFileRecord &record) { + if (record.isValid()) { + relPath = record.path(); + } + }); + + if (relPath.isEmpty()) { + completionHandler(0, [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorNoSuchItem + userInfo:@{NSLocalizedDescriptionKey: @"Item not found in journal"}]); + return; + } + + auto state = vfs->pinState(relPath); + if (state) { + completionHandler(static_cast(*state), nil); + } else { + // No explicit pin state -- return Inherited (0) + completionHandler(static_cast(PinState::Inherited), nil); + } + }, Qt::QueuedConnection); +} + +- (void)setPinState:(NSInteger)pinState + forFileId:(NSString *)fileId + completionHandler:(void (^)(NSError * _Nullable))completionHandler { + + NSString *fileIdCopy = [fileId copy]; + QPointer vfs = _vfs; + + QMetaObject::invokeMethod(vfs, [vfs, fileIdCopy, pinState, completionHandler]() { + if (!vfs) { + completionHandler([NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: @"Sync engine unavailable"}]); + return; + } + + auto *journal = vfs->params().journal; + if (!journal) { + completionHandler([NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: @"No journal available"}]); + return; + } + + QString relPath; + const auto qFileId = QString::fromNSString(fileIdCopy).toUtf8(); + journal->getFileRecordsByFileId(qFileId, [&relPath](const SyncJournalFileRecord &record) { + if (record.isValid()) { + relPath = record.path(); + } + }); + + if (relPath.isEmpty()) { + completionHandler([NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorNoSuchItem + userInfo:@{NSLocalizedDescriptionKey: @"Item not found in journal"}]); + return; + } + + const auto state = static_cast(pinState); + const bool ok = vfs->setPinState(relPath, state); + if (ok) { + completionHandler(nil); + } else { + completionHandler([NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: @"Failed to set pin state"}]); + } + }, Qt::QueuedConnection); +} + +- (void)ping:(void (^)(BOOL))handler { + qCDebug(lcNsfpXpc) << "ping received"; + handler(YES); +} + +- (void)enumerateItems:(NSString *)containerId + cursor:(NSString *)cursor + completionHandler:(void (^)(NSArray * _Nullable, + NSString * _Nullable, + NSError * _Nullable))completionHandler { + + NSString *containerIdCopy = [containerId copy]; + NSString *cursorCopy = [cursor copy]; + QPointer vfs = _vfs; + + QMetaObject::invokeMethod(vfs, [vfs, containerIdCopy, cursorCopy, completionHandler]() { + if (!vfs) { + completionHandler(nil, nil, + [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: @"Sync engine unavailable"}]); + return; + } + + auto *journal = vfs->params().journal; + if (!journal) { + completionHandler(nil, nil, + [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: @"No journal available"}]); + return; + } + + const auto qContainerId = QString::fromNSString(containerIdCopy); + const int offset = QString::fromNSString(cursorCopy).toInt(); // empty -> 0 + + // Determine the parent path. Root container => enumerate top-level items. + QString parentPath; + if (!qContainerId.isEmpty()) { + // Look up the path for this fileId + const auto qFileIdBytes = qContainerId.toUtf8(); + journal->getFileRecordsByFileId(qFileIdBytes, [&parentPath](const SyncJournalFileRecord &record) { + if (record.isValid()) { + parentPath = record.path(); + } + }); + if (parentPath.isEmpty()) { + completionHandler(nil, nil, + [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorNoSuchItem + userInfo:@{NSLocalizedDescriptionKey: @"Container not found in journal"}]); + return; + } + } + // parentPath empty means root + + // Collect children from the journal + QVector children; + journal->listFilesInPath(parentPath, [&children](const SyncJournalFileRecord &record) { + children.append(record); + }); + + // Apply pagination + const int total = children.size(); + const int start = qMin(offset, total); + const int end = qMin(start + ENUMERATE_PAGE_SIZE, total); + + NSMutableArray *items = [NSMutableArray arrayWithCapacity:end - start]; + for (int i = start; i < end; ++i) { + const auto &rec = children[i]; + NSDictionary *dict = @{ + @"fileId" : QString::fromUtf8(rec.fileId()).toNSString(), + @"path" : rec.path().toNSString(), + @"name" : rec.name().toNSString(), + @"isDirectory" : @(rec.isDirectory()), + @"size" : @(rec.size()), + @"modtime" : @(rec.modtime()), + @"etag" : rec.etag().toNSString(), + @"isVirtualFile" : @(rec.isVirtualFile()), + }; + [items addObject:dict]; + } + + NSString *nextCursor = nil; + if (end < total) { + nextCursor = [NSString stringWithFormat:@"%d", end]; + } + + completionHandler(items, nextCursor, nil); + }, Qt::QueuedConnection); +} + +- (void)itemForIdentifier:(NSString *)identifier + completionHandler:(void (^)(NSDictionary * _Nullable, + NSError * _Nullable))completionHandler { + + NSString *identifierCopy = [identifier copy]; + QPointer vfs = _vfs; + + QMetaObject::invokeMethod(vfs, [vfs, identifierCopy, completionHandler]() { + if (!vfs) { + completionHandler(nil, + [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: @"Sync engine unavailable"}]); + return; + } + + auto *journal = vfs->params().journal; + if (!journal) { + completionHandler(nil, + [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: @"No journal available"}]); + return; + } + + const auto qFileId = QString::fromNSString(identifierCopy).toUtf8(); + SyncJournalFileRecord found; + journal->getFileRecordsByFileId(qFileId, [&found](const SyncJournalFileRecord &record) { + if (record.isValid() && !found.isValid()) { + found = record; + } + }); + + if (!found.isValid()) { + completionHandler(nil, + [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorNoSuchItem + userInfo:@{NSLocalizedDescriptionKey: @"Item not found in journal"}]); + return; + } + + NSDictionary *dict = @{ + @"fileId" : QString::fromUtf8(found.fileId()).toNSString(), + @"path" : found.path().toNSString(), + @"name" : found.name().toNSString(), + @"isDirectory" : @(found.isDirectory()), + @"size" : @(found.size()), + @"modtime" : @(found.modtime()), + @"etag" : found.etag().toNSString(), + @"isVirtualFile" : @(found.isVirtualFile()), + }; + + completionHandler(dict, nil); + }, Qt::QueuedConnection); +} + +@end + +// --------------------------------------------------------------------------- +// C++ Private implementation (PIMPL) +// --------------------------------------------------------------------------- + +namespace OCC { + +struct NsfpXpcHandler::Private +{ + NSXPCListener *listener = nil; + NsfpXpcDelegate *delegate = nil; +}; + +NsfpXpcHandler::NsfpXpcHandler(Vfs *vfs, QObject *parent) + : QObject(parent) + , _p(std::make_unique()) + , _vfs(vfs) +{ +} + +NsfpXpcHandler::~NsfpXpcHandler() +{ + stopListener(); +} + +void NsfpXpcHandler::startListener() +{ + if (_p->listener) { + qCDebug(lcNsfpXpc) << "Listener already started"; + return; + } + + qCInfo(lcNsfpXpc) << "Starting anonymous NSXPCListener (endpoint shared via App Group container)"; + + _p->delegate = [[NsfpXpcDelegate alloc] initWithVfs:QPointer(_vfs) + handler:QPointer(this)]; + + // Use an anonymous listener instead of initWithMachServiceName: because + // unsandboxed apps cannot register Mach services with launchd. + _p->listener = [NSXPCListener anonymousListener]; + _p->listener.delegate = _p->delegate; + [_p->listener resume]; + + // Write the listener endpoint to the App Group container so the extension + // can establish an XPC connection. NSXPCListenerEndpoint conforms to + // NSSecureCoding; the serialized form carries a Mach send right that the + // kernel transfers to whichever process unarchives the data. The endpoint + // becomes invalid when this process exits, but that is expected — the + // extension will retry on the next launch. + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (containerURL) { + NSXPCListenerEndpoint *endpoint = _p->listener.endpoint; + if (endpoint) { + NSError *archiveError = nil; + NSData *data = [NSKeyedArchiver archivedDataWithRootObject:endpoint + requiringSecureCoding:YES + error:&archiveError]; + if (data && !archiveError) { + NSURL *endpointURL = [containerURL URLByAppendingPathComponent:kOpenCloudXPCEndpointFilename]; + [data writeToURL:endpointURL atomically:YES]; + qCInfo(lcNsfpXpc) << "XPC endpoint written to App Group container"; + } else { + qCWarning(lcNsfpXpc) << "Failed to archive XPC endpoint:" + << QString::fromNSString(archiveError.localizedDescription); + } + } + } + + qCInfo(lcNsfpXpc) << "NSXPCListener started"; +} + +void NsfpXpcHandler::stopListener() +{ + if (!_p->listener) { + return; + } + + qCInfo(lcNsfpXpc) << "Stopping NSXPCListener"; + [_p->listener invalidate]; + _p->listener = nil; + _p->delegate = nil; + + // Remove the endpoint file so the extension does not try to connect + // to a dead listener. + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (containerURL) { + NSURL *endpointURL = [containerURL URLByAppendingPathComponent:kOpenCloudXPCEndpointFilename]; + [[NSFileManager defaultManager] removeItemAtURL:endpointURL error:nil]; + } +} + +} // namespace OCC diff --git a/src/plugins/vfs/nsfp/vfs_nsfp.h b/src/plugins/vfs/nsfp/vfs_nsfp.h new file mode 100644 index 0000000000..286ed65567 --- /dev/null +++ b/src/plugins/vfs/nsfp/vfs_nsfp.h @@ -0,0 +1,85 @@ +// VfsNSFP header -- macOS NSFileProvider-based virtual file system plugin. +#pragma once + +#include "common/plugin.h" +#include "vfs/vfs.h" + +#include +#include +#include +#include + +#include + +#ifdef __OBJC__ +#import +#import +#endif + +namespace OCC { + +class NsfpDomainManager; +class NsfpXpcHandler; + +class VfsNSFP : public Vfs +{ + Q_OBJECT + +public: + explicit VfsNSFP(QObject *parent = nullptr); + ~VfsNSFP() override; + + [[nodiscard]] Mode mode() const override; + + void stop() override; + void unregisterFolder() override; + + [[nodiscard]] bool socketApiPinStateActionsShown() const override; + + [[nodiscard]] Result createPlaceholder(const SyncFileItem &item) override; + + [[nodiscard]] bool needsMetadataUpdate(const SyncFileItem &item) override; + [[nodiscard]] bool isDehydratedPlaceholder(const QString &filePath) override; + [[nodiscard]] LocalInfo statTypeVirtualFile(const std::filesystem::directory_entry &path, ItemType type) override; + + [[nodiscard]] bool setPinState(const QString &relFilePath, PinState state) override; + [[nodiscard]] Optional pinState(const QString &relFilePath) override; + [[nodiscard]] AvailabilityResult availability(const QString &folderPath) override; + +public Q_SLOTS: + void fileStatusChanged(const QString &systemFileName, OCC::SyncFileStatus fileStatus) override; + +protected: + [[nodiscard]] Result updateMetadata( + const SyncFileItem &item, const QString &filePath, const QString &replacesFile) override; + void startImpl(const VfsSetupParams ¶ms) override; + +private: + /// Derives a stable domain identifier from account UUID and space ID. + [[nodiscard]] QString domainIdentifier() const; + + std::unique_ptr _domainManager; + std::unique_ptr _xpcHandler; + QString _domainId; + + /// Periodic timer that triggers sync cycles and metadata refresh so the + /// Finder view stays current with server-side changes (like iCloud/OneDrive). + QTimer _pollTimer; + + /// In-memory pin state cache. Key: relative file path. Value: PinState. + /// For NSFP the journal does not store pin state natively, so we keep + /// an in-memory map that persists for the lifetime of the VFS instance. + QMap _pinStates; +}; + +class NsfpVfsPluginFactory : public QObject, public DefaultPluginFactory +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "eu.opencloud.PluginFactory" FILE "libsync/vfs/vfspluginmetadata.json") + Q_INTERFACES(OCC::PluginFactory) + +public: + Result prepare(const QString &path, const QUuid &accountUuid) const override; +}; + +} // namespace OCC diff --git a/src/plugins/vfs/nsfp/vfs_nsfp.json b/src/plugins/vfs/nsfp/vfs_nsfp.json new file mode 100644 index 0000000000..bee01e317b --- /dev/null +++ b/src/plugins/vfs/nsfp/vfs_nsfp.json @@ -0,0 +1,4 @@ +{ + "type": "vfs", + "version": "nsfp" +} diff --git a/src/plugins/vfs/nsfp/vfs_nsfp.mm b/src/plugins/vfs/nsfp/vfs_nsfp.mm new file mode 100644 index 0000000000..18828fba64 --- /dev/null +++ b/src/plugins/vfs/nsfp/vfs_nsfp.mm @@ -0,0 +1,946 @@ +// VfsNSFP implementation -- macOS NSFileProvider-based virtual file system plugin. +// fileStatusChanged, and eviction integration. + +#include "vfs_nsfp.h" + +#import + +#include "nsfpdomainmanager.h" +#include "nsfpxpchandler.h" + +#include "common/pinstate.h" +#include "common/syncjournaldb.h" +#include "libsync/account.h" +#include "creds/abstractcredentials.h" +#include "creds/httpcredentials.h" +#include "syncengine.h" +#include "syncfileitem.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#import + +// Shared constants from the FileProvider extension header. +#import "FileProviderXPCService.h" + +Q_LOGGING_CATEGORY(lcVfsNSFP, "sync.vfs.nsfp", QtInfoMsg) + +using namespace OCC; + +/// Writes the WebDAV URL and access token to the App Group shared container +/// so the FileProvider extension can download file contents directly from the server. +static void syncConfigToSharedContainer(const VfsSetupParams ¶ms) +{ + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) { + qCWarning(lcVfsNSFP) << "syncConfigToSharedContainer: cannot access App Group container"; + return; + } + + // Extract access token from credentials. + QString accessToken; + if (auto *httpCreds = qobject_cast(params.account->credentials())) { + accessToken = httpCreds->accessToken(); + } + + // Don't overwrite an existing config with an empty token — the extension + // would lose the ability to download files until the token is refreshed. + if (accessToken.isEmpty()) { + // Check if a config with a valid token already exists. + NSURL *existingConfig = [containerURL URLByAppendingPathComponent:@"fileprovider_config.plist"]; + NSData *existingData = [NSData dataWithContentsOfURL:existingConfig]; + if (existingData) { + NSDictionary *existing = [NSPropertyListSerialization propertyListWithData:existingData + options:NSPropertyListImmutable + format:nil error:nil]; + NSString *existingToken = existing[@"accessToken"]; + if (existingToken && existingToken.length > 0) { + qCInfo(lcVfsNSFP) << "syncConfigToSharedContainer: skipping write — token empty but existing config has valid token"; + return; + } + } + qCWarning(lcVfsNSFP) << "syncConfigToSharedContainer: writing config with empty token (credentials not yet available)"; + } + + // OCIS space IDs use '$' as a separator (e.g. driveId$dirId). When constructing a + // WebDAV URL the server expects this character to be percent-encoded as '%24'. + // QUrl considers '$' a valid path character (sub-delimiter per RFC 3986) and never + // encodes it, even with QUrl::FullyEncoded. We must replace it manually. + auto davUrl = params.baseUrl().toString(QUrl::FullyEncoded); + davUrl.replace(QLatin1Char('$'), QStringLiteral("%24")); + + NSDictionary *config = @{ + @"davUrl": davUrl.toNSString(), + @"accessToken": accessToken.toNSString(), + }; + + NSURL *configURL = [containerURL URLByAppendingPathComponent:@"fileprovider_config.plist"]; + NSError *error = nil; + NSData *data = [NSPropertyListSerialization dataWithPropertyList:config + format:NSPropertyListBinaryFormat_v1_0 + options:0 + error:&error]; + if (!data || error) { + qCWarning(lcVfsNSFP) << "syncConfigToSharedContainer: failed to serialize config:" << error.localizedDescription.UTF8String; + return; + } + + // Make file readable by the extension (group container is accessible to all group members). + [data writeToURL:configURL atomically:YES]; + // Set read permissions for group members + [[NSFileManager defaultManager] setAttributes:@{NSFilePosixPermissions: @0644} + ofItemAtPath:configURL.path + error:nil]; + qCInfo(lcVfsNSFP) << "syncConfigToSharedContainer: wrote config with davUrl" << davUrl; +} + +/// Writes all file records from the sync journal to a plist file in the +/// App Group shared container so the FileProvider extension can enumerate items +/// without needing an XPC connection to the main app. +static void syncMetadataToSharedContainer(SyncJournalDb *journal) +{ + if (!journal) { + qCWarning(lcVfsNSFP) << "syncMetadataToSharedContainer: no journal"; + return; + } + + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) { + qCWarning(lcVfsNSFP) << "syncMetadataToSharedContainer: App Group container not accessible"; + return; + } + + // Collect all file records from the journal. + // First pass: collect records and build a path → fileId map. + struct ItemInfo { + QString path; + QString name; + QString fileId; + QString parentPath; + bool isDirectory; + int64_t size; + time_t modtime; + QString etag; + bool isVirtualFile; + }; + + QVector records; + QMap pathToFileId; + int totalCallbacks = 0; + int invalidCount = 0; + int dirCount = 0; + int virtualFileCount = 0; + int otherCount = 0; + + journal->getFilesBelowPath(QString(), [&](const SyncJournalFileRecord &rec) { + totalCallbacks++; + if (!rec.isValid()) { + invalidCount++; + return; + } + + ItemInfo info; + info.path = rec.path(); + info.name = rec.name(); + info.fileId = QString::fromUtf8(rec.fileId()); + info.isDirectory = rec.isDirectory(); + info.size = rec.size(); + info.modtime = rec.modtime(); + info.etag = rec.etag(); + info.isVirtualFile = rec.isVirtualFile(); + + if (info.isDirectory) { + dirCount++; + } else if (info.isVirtualFile) { + virtualFileCount++; + } else { + otherCount++; + } + + // Derive parent path. + const auto lastSlash = info.path.lastIndexOf(QLatin1Char('/')); + info.parentPath = (lastSlash > 0) ? info.path.left(lastSlash) : QString(); + + pathToFileId[info.path] = info.fileId; + records.append(info); + }); + + os_log_fault(OS_LOG_DEFAULT, "syncMetadataToSharedContainer: callbacks=%d invalid=%d dirs=%d virtualFiles=%d other=%d records=%d", + totalCallbacks, invalidCount, dirCount, virtualFileCount, otherCount, (int)records.size()); + + // If the journal query returned no virtual files, they may have been deleted + // by WAL operations (e.g., discovery marking them as stale because no local + // placeholder exists in NSFP mode). Fall back to reading the base DB directly + // with immutable=1 to recover virtual file records. + if (virtualFileCount == 0) { + const auto dbPath = journal->databaseFilePath(); + const auto uri = QStringLiteral("file://%1?immutable=1").arg(dbPath); + sqlite3 *db = nullptr; + int rc = sqlite3_open_v2(uri.toUtf8().constData(), &db, + SQLITE_OPEN_READONLY | SQLITE_OPEN_URI, nullptr); + if (rc == SQLITE_OK && db) { + sqlite3_stmt *stmt = nullptr; + rc = sqlite3_prepare_v2(db, + "SELECT path, fileid, filesize, modtime, md5 FROM metadata WHERE type=4", + -1, &stmt, nullptr); + if (rc == SQLITE_OK && stmt) { + int recoveredCount = 0; + while (sqlite3_step(stmt) == SQLITE_ROW) { + ItemInfo info; + info.path = QString::fromUtf8( + reinterpret_cast(sqlite3_column_text(stmt, 0))); + info.fileId = QString::fromUtf8( + reinterpret_cast(sqlite3_column_text(stmt, 1))); + info.size = sqlite3_column_int64(stmt, 2); + info.modtime = sqlite3_column_int64(stmt, 3); + info.etag = QString::fromUtf8( + reinterpret_cast(sqlite3_column_text(stmt, 4))); + info.isDirectory = false; + info.isVirtualFile = true; + + // Derive name and parent path from path. + const auto lastSlash = info.path.lastIndexOf(QLatin1Char('/')); + info.name = (lastSlash >= 0) ? info.path.mid(lastSlash + 1) : info.path; + info.parentPath = (lastSlash > 0) ? info.path.left(lastSlash) : QString(); + + // Only add if not already in records (avoid duplicates). + bool alreadyPresent = false; + for (const auto &existing : records) { + if (existing.path == info.path) { + alreadyPresent = true; + break; + } + } + if (!alreadyPresent) { + pathToFileId[info.path] = info.fileId; + records.append(info); + recoveredCount++; + } + } + sqlite3_finalize(stmt); + os_log_fault(OS_LOG_DEFAULT, + "syncMetadataToSharedContainer: recovered %d virtual files from base DB", + recoveredCount); + } + sqlite3_close(db); + } else { + os_log_fault(OS_LOG_DEFAULT, + "syncMetadataToSharedContainer: failed to open immutable DB: %{public}s", + sqlite3_errmsg(db)); + if (db) sqlite3_close(db); + } + } + + // Second pass: resolve parent file IDs and build the plist array. + NSMutableArray *items = [NSMutableArray arrayWithCapacity:records.size()]; + for (const auto &info : records) { + NSString *parentId; + if (info.parentPath.isEmpty()) { + parentId = NSFileProviderRootContainerItemIdentifier; + } else { + const auto it = pathToFileId.find(info.parentPath); + parentId = (it != pathToFileId.end()) ? it.value().toNSString() + : NSFileProviderRootContainerItemIdentifier; + } + + NSDictionary *dict = @{ + @"fileId" : info.fileId.toNSString() ?: @"", + @"filename" : info.name.toNSString() ?: @"", + @"path" : info.path.toNSString() ?: @"", + @"parentPath" : info.parentPath.toNSString() ?: @"", + @"parentId" : parentId, + @"isDirectory" : @(info.isDirectory), + @"size" : @(info.size), + @"modtime" : @(info.modtime), + @"etag" : info.etag.toNSString() ?: @"", + @"isVirtualFile" : @(info.isVirtualFile), + // isDownloaded: true for fully hydrated files, false for virtual/dehydrated placeholders. + // Directories are always considered "downloaded" since they have no content to fetch. + @"isDownloaded" : @(info.isDirectory || !info.isVirtualFile), + }; + [items addObject:dict]; + } + + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; + NSError *writeError = nil; + NSData *data = [NSPropertyListSerialization dataWithPropertyList:items + format:NSPropertyListBinaryFormat_v1_0 + options:0 + error:&writeError]; + if (data && !writeError) { + [data writeToURL:metadataURL atomically:YES]; + qCInfo(lcVfsNSFP) << "syncMetadataToSharedContainer: wrote" << items.count + << "items to" << QString::fromNSString(metadataURL.path); + } else { + qCWarning(lcVfsNSFP) << "syncMetadataToSharedContainer: write failed:" + << QString::fromNSString(writeError.localizedDescription); + } +} + +VfsNSFP::VfsNSFP(QObject *parent) + : Vfs(parent) +{ +} + +VfsNSFP::~VfsNSFP() +{ + stop(); +} + +Vfs::Mode VfsNSFP::mode() const +{ + return Vfs::Mode::MacOSNSFileProvider; +} + +QString VfsNSFP::domainIdentifier() const +{ + return _domainId; +} + +void VfsNSFP::stop() +{ + _pollTimer.stop(); + + // Tear down the XPC handler first so the extension gets a clean disconnect. + if (_xpcHandler) { + _xpcHandler->stopListener(); + _xpcHandler.reset(); + } + + if (!_domainManager || _domainId.isEmpty()) { + qCDebug(lcVfsNSFP) << "stop() called but no domain manager or domain ID set"; + return; + } + + qCInfo(lcVfsNSFP) << "stop() — invalidating manager for domain:" << _domainId; + _domainManager->invalidateManager(_domainId); +} + +void VfsNSFP::unregisterFolder() +{ + if (!_domainManager || _domainId.isEmpty()) { + qCDebug(lcVfsNSFP) << "unregisterFolder() called but no domain manager or domain ID set"; + return; + } + + qCInfo(lcVfsNSFP) << "unregisterFolder() — removing domain:" << _domainId; + + // Capture a pointer to this for the completion handler + QPointer self(this); + const auto domainId = _domainId; + + _domainManager->removeDomain(domainId, [self, domainId](const QString &errorMessage) { + if (!self) { + return; + } + + if (errorMessage.isEmpty()) { + QMetaObject::invokeMethod(self, [self, domainId]() { + if (self) { + qCInfo(lcVfsNSFP) << "Domain removed successfully:" << domainId; + } + }, Qt::QueuedConnection); + } else { + QMetaObject::invokeMethod(self, [self, errorMessage]() { + if (self) { + qCWarning(lcVfsNSFP) << "Failed to remove domain:" << errorMessage; + Q_EMIT self->error(errorMessage); + } + }, Qt::QueuedConnection); + } + }); + + _domainId.clear(); +} + +bool VfsNSFP::socketApiPinStateActionsShown() const +{ + return true; +} + +Result VfsNSFP::createPlaceholder(const SyncFileItem &item) +{ + qCInfo(lcVfsNSFP) << "createPlaceholder() for:" << item.localName() + << "fileId:" << item._fileId << "type:" << item._type; + + if (!_domainManager || _domainId.isEmpty()) { + return {tr("Cannot create placeholder: domain not registered")}; + } + + // Write a journal record marking this item as a virtual file (dehydrated placeholder). + auto *journal = params().journal; + if (!journal) { + return {tr("Cannot create placeholder: no sync journal available")}; + } + + // Create a journal record from the sync file item with virtual file type. + auto record = SyncJournalFileRecord::fromSyncFileItem(item); + const auto result = journal->setFileRecord(record); + if (!result) { + const auto errorMsg = result.error(); + qCWarning(lcVfsNSFP) << "Failed to write journal record:" << errorMsg; + return {errorMsg}; + } + + // Determine the parent container identifier. If the file is at root level, + // use an empty string which signalEnumerator maps to NSFileProviderRootContainerItemIdentifier. + const auto localName = item.localName(); + const auto lastSlash = localName.lastIndexOf(QLatin1Char('/')); + QString parentContainerId; + + if (lastSlash > 0) { + // Has a parent folder -- look up its fileId from the journal. + const auto parentPath = localName.left(lastSlash); + const auto parentRecord = journal->getFileRecord(parentPath); + if (parentRecord.isValid()) { + parentContainerId = QString::fromUtf8(parentRecord.fileId()); + } + } + // If parentContainerId is empty, signalEnumerator will use root container. + + // Update the shared metadata file so the extension can see the new item. + syncMetadataToSharedContainer(journal); + + // Signal the File Provider framework to re-enumerate the parent container + // so Finder picks up the new placeholder. + _domainManager->signalEnumerator(_domainId, parentContainerId); + + qCInfo(lcVfsNSFP) << "Placeholder created successfully for:" << item.localName(); + return {}; +} + +bool VfsNSFP::needsMetadataUpdate(const SyncFileItem &item) +{ + // Check the journal for the current record and compare metadata fields. + auto *journal = params().journal; + if (!journal) { + return true; + } + + const auto record = journal->getFileRecord(item.localName()); + if (!record.isValid()) { + // No record means we need to create one. + return true; + } + + // If etag, modtime, or size differ from what the journal has, we need an update. + if (record.etag() != item._etag) { + return true; + } + if (record.modtime() != item._modtime) { + return true; + } + if (record.size() != item._size) { + return true; + } + + return false; +} + +bool VfsNSFP::isDehydratedPlaceholder(const QString &filePath) +{ + // For NSFP the journal is the source of truth for placeholder state. + // Derive the relative path from the absolute filePath. + auto *journal = params().journal; + if (!journal) { + return false; + } + + const auto fsPath = params().filesystemPath(); + QString relPath = filePath; + if (relPath.startsWith(fsPath)) { + relPath = relPath.mid(fsPath.length()); + if (relPath.startsWith(QLatin1Char('/'))) { + relPath = relPath.mid(1); + } + } + + const auto record = journal->getFileRecord(relPath); + if (!record.isValid()) { + return false; + } + + // If the journal record says virtual file, it is a dehydrated placeholder. + if (record.isVirtualFile()) { + return true; + } + + // If the record says it is a regular file, it is not dehydrated. + return false; +} + +LocalInfo VfsNSFP::statTypeVirtualFile(const std::filesystem::directory_entry &path, ItemType type) +{ + // During local discovery, check the journal to determine if a file should + // be treated as a virtual (dehydrated) file. For NSFP the journal is the + // source of truth since the framework manages the on-disk state. + if (type == ItemTypeFile) { + auto *journal = params().journal; + if (journal) { + const auto fsPath = std::filesystem::path(params().filesystemPath().toStdString()); + const auto relStdPath = std::filesystem::relative(path.path(), fsPath); + const auto relPath = QString::fromStdString(relStdPath.generic_string()); + + const auto record = journal->getFileRecord(relPath); + if (record.isValid()) { + if (record.type() == ItemTypeVirtualFile) { + // Check pin state to decide if it wants to be downloaded. + const auto pinSt = pinState(relPath); + if (pinSt && *pinSt == PinState::AlwaysLocal) { + type = ItemTypeVirtualFileDownload; + } else { + type = ItemTypeVirtualFile; + } + } else if (record.type() == ItemTypeFile) { + // Check if the file should be dehydrated. + const auto pinSt = pinState(relPath); + if (pinSt && *pinSt == PinState::OnlineOnly) { + type = ItemTypeVirtualFileDehydration; + } + } + } + } + } + + qCDebug(lcVfsNSFP) << "statTypeVirtualFile:" << path.path().c_str() << Utility::enumToString(type); + return LocalInfo(path, type); +} + +bool VfsNSFP::setPinState(const QString &relFilePath, PinState state) +{ + qCInfo(lcVfsNSFP) << "setPinState()" << relFilePath << static_cast(state); + + // Store in the in-memory map. + _pinStates[relFilePath] = state; + + if (!_domainManager || _domainId.isEmpty()) { + qCWarning(lcVfsNSFP) << "setPinState: domain not registered"; + return false; + } + + // For AlwaysLocal, trigger hydration of the file (if dehydrated). + if (state == PinState::AlwaysLocal) { + auto *journal = params().journal; + if (journal) { + const auto record = journal->getFileRecord(relFilePath); + if (record.isValid() && record.isVirtualFile()) { + qCInfo(lcVfsNSFP) << "setPinState: AlwaysLocal — triggering hydration for:" << relFilePath; + // Signal the enumerator so the extension picks up the changed pin state + // and can request hydration. + QString parentContainerId; + const auto lastSlash = relFilePath.lastIndexOf(QLatin1Char('/')); + if (lastSlash > 0) { + const auto parentPath = relFilePath.left(lastSlash); + const auto parentRecord = journal->getFileRecord(parentPath); + if (parentRecord.isValid()) { + parentContainerId = QString::fromUtf8(parentRecord.fileId()); + } + } + _domainManager->signalEnumerator(_domainId, parentContainerId); + } + } + } + + // For OnlineOnly, trigger eviction of the file (free local data). + if (state == PinState::OnlineOnly) { + auto *journal = params().journal; + if (journal) { + const auto record = journal->getFileRecord(relFilePath); + if (record.isValid() && !record.isVirtualFile()) { + qCInfo(lcVfsNSFP) << "setPinState: OnlineOnly — triggering eviction for:" << relFilePath; + const auto fileId = QString::fromUtf8(record.fileId()); + _domainManager->evictItem(_domainId, fileId, [relFilePath](const QString &errorMsg) { + if (errorMsg.isEmpty()) { + qCInfo(lcVfsNSFP) << "Eviction succeeded for:" << relFilePath; + } else { + qCWarning(lcVfsNSFP) << "Eviction failed for:" << relFilePath << errorMsg; + } + }); + } + } + } + + return true; +} + +Optional VfsNSFP::pinState(const QString &relFilePath) +{ + // Walk up the path to find the effective pin state (inherited resolution). + auto it = _pinStates.constFind(relFilePath); + if (it != _pinStates.constEnd()) { + const auto state = it.value(); + if (state != PinState::Inherited) { + return state; + } + } + + // Walk up parent directories to resolve inheritance. + QString path = relFilePath; + while (true) { + const auto lastSlash = path.lastIndexOf(QLatin1Char('/')); + if (lastSlash <= 0) { + // Check root + auto rootIt = _pinStates.constFind(QString()); + if (rootIt != _pinStates.constEnd() && rootIt.value() != PinState::Inherited) { + return rootIt.value(); + } + break; + } + path = path.left(lastSlash); + auto parentIt = _pinStates.constFind(path); + if (parentIt != _pinStates.constEnd() && parentIt.value() != PinState::Inherited) { + return parentIt.value(); + } + } + + // No explicit state found -- default to Unspecified for NSFP. + return PinState::Unspecified; +} + +Vfs::AvailabilityResult VfsNSFP::availability(const QString &folderPath) +{ + // Check pin state first. + const auto basePinSt = pinState(folderPath); + if (basePinSt) { + switch (*basePinSt) { + case PinState::AlwaysLocal: + return VfsItemAvailability::AlwaysLocal; + case PinState::OnlineOnly: + return VfsItemAvailability::OnlineOnly; + case PinState::Inherited: + case PinState::Unspecified: + case PinState::Excluded: + break; + } + } + + // Check the journal record for hydration status. + auto *journal = params().journal; + if (!journal) { + return AvailabilityError::DbError; + } + + const auto record = journal->getFileRecord(folderPath); + if (!record.isValid()) { + return AvailabilityError::NoSuchItem; + } + + if (record.isDirectory()) { + // For directories, check children. + bool hasHydrated = false; + bool hasDehydrated = false; + journal->listFilesInPath(folderPath, [&hasHydrated, &hasDehydrated](const SyncJournalFileRecord &child) { + if (child.isVirtualFile()) { + hasDehydrated = true; + } else if (child.isFile()) { + hasHydrated = true; + } + }); + + if (hasHydrated && hasDehydrated) { + return VfsItemAvailability::Mixed; + } + if (hasDehydrated) { + return VfsItemAvailability::AllDehydrated; + } + return VfsItemAvailability::AllHydrated; + } + + // Single file + if (record.isVirtualFile()) { + return VfsItemAvailability::AllDehydrated; + } + return VfsItemAvailability::AllHydrated; +} + +void VfsNSFP::fileStatusChanged(const QString &systemFileName, SyncFileStatus fileStatus) +{ + if (!_domainManager || _domainId.isEmpty()) { + return; + } + + qCDebug(lcVfsNSFP) << "fileStatusChanged:" << systemFileName << fileStatus.tag(); + + // Derive the parent container identifier to signal the correct enumerator. + auto *journal = params().journal; + if (!journal) { + return; + } + + // Convert system path to relative path. + const auto filesystemPath = params().filesystemPath(); + QString relPath = systemFileName; + if (relPath.startsWith(filesystemPath)) { + relPath = relPath.mid(filesystemPath.length()); + if (relPath.startsWith(QLatin1Char('/'))) { + relPath = relPath.mid(1); + } + } + + // Determine the parent container identifier for signalling. + QString parentContainerId; + const auto lastSlash = relPath.lastIndexOf(QLatin1Char('/')); + if (lastSlash > 0) { + const auto parentPath = relPath.left(lastSlash); + const auto parentRecord = journal->getFileRecord(parentPath); + if (parentRecord.isValid()) { + parentContainerId = QString::fromUtf8(parentRecord.fileId()); + } + } + + switch (fileStatus.tag()) { + case SyncFileStatus::StatusSync: + // File is syncing -- signal enumerator so Finder shows a progress indicator. + qCDebug(lcVfsNSFP) << "StatusSync — signalling enumerator for:" << relPath; + _domainManager->signalEnumerator(_domainId, parentContainerId); + break; + + case SyncFileStatus::StatusUpToDate: + // File is synced -- signal enumerator so Finder shows a checkmark badge. + qCDebug(lcVfsNSFP) << "StatusUpToDate — signalling enumerator for:" << relPath; + _domainManager->signalEnumerator(_domainId, parentContainerId); + break; + + case SyncFileStatus::StatusError: + // File has an error -- signal enumerator so Finder shows an error badge. + qCDebug(lcVfsNSFP) << "StatusError — signalling enumerator for:" << relPath; + _domainManager->signalEnumerator(_domainId, parentContainerId); + break; + + case SyncFileStatus::StatusExcluded: + // Mark excluded files with the Excluded pin state. + setPinState(relPath, PinState::Excluded); + break; + + case SyncFileStatus::StatusWarning: + // File has a warning -- signal enumerator so Finder shows a warning badge. + qCDebug(lcVfsNSFP) << "StatusWarning — signalling enumerator for:" << relPath; + _domainManager->signalEnumerator(_domainId, parentContainerId); + break; + + case SyncFileStatus::StatusNone: + // No specific action for StatusNone. + break; + } +} + +Result VfsNSFP::updateMetadata( + const SyncFileItem &item, const QString &filePath, const QString &replacesFile) +{ + Q_UNUSED(replacesFile) + + qCInfo(lcVfsNSFP) << "updateMetadata() for:" << item.localName() + << "filePath:" << filePath << "fileId:" << item._fileId; + + if (!_domainManager || _domainId.isEmpty()) { + return {tr("Cannot update metadata: domain not registered")}; + } + + // Update the journal record with the latest metadata. + auto *journal = params().journal; + if (!journal) { + return {tr("Cannot update metadata: no sync journal available")}; + } + + auto record = SyncJournalFileRecord::fromSyncFileItem(item); + const auto result = journal->setFileRecord(record); + if (!result) { + const auto errorMsg = result.error(); + qCWarning(lcVfsNSFP) << "Failed to update journal record:" << errorMsg; + return {errorMsg}; + } + + // Determine parent container for the signal. + const auto localName = item.localName(); + const auto lastSlash = localName.lastIndexOf(QLatin1Char('/')); + QString parentContainerId; + + if (lastSlash > 0) { + const auto parentPath = localName.left(lastSlash); + const auto parentRecord = journal->getFileRecord(parentPath); + if (parentRecord.isValid()) { + parentContainerId = QString::fromUtf8(parentRecord.fileId()); + } + } + + // Update the shared metadata so the extension sees the changes. + syncMetadataToSharedContainer(journal); + + // Signal the File Provider framework to refresh Finder's view. + _domainManager->signalEnumerator(_domainId, parentContainerId); + + qCInfo(lcVfsNSFP) << "Metadata updated successfully for:" << item.localName(); + return Vfs::ConvertToPlaceholderResult::Ok; +} + +void VfsNSFP::startImpl(const VfsSetupParams ¶ms) +{ + qCInfo(lcVfsNSFP) << "startImpl() — registering NSFileProvider domain"; + + // Volume type check: NSFileProvider requires APFS or HFS+ filesystem. + const auto syncRoot = params.filesystemPath(); + struct statfs fsInfo; + if (statfs(syncRoot.toUtf8().constData(), &fsInfo) == 0) { + const auto fsType = QString::fromUtf8(fsInfo.f_fstypename); + if (fsType.compare(QLatin1String("apfs"), Qt::CaseInsensitive) != 0 + && fsType.compare(QLatin1String("hfs"), Qt::CaseInsensitive) != 0) { + const auto errorMsg = tr("NSFileProvider requires APFS or HFS+ volume, but sync root is on %1").arg(fsType); + qCWarning(lcVfsNSFP) << errorMsg; + Q_EMIT error(errorMsg); + return; + } + qCInfo(lcVfsNSFP) << "Volume type check passed:" << fsType; + } else { + qCWarning(lcVfsNSFP) << "Failed to stat filesystem for sync root:" << syncRoot; + } + + // Detect existing xattr placeholders from a prior xattr VFS mode. + // Check if the sync root directory has extended attributes that indicate + // it was previously managed by the xattr VFS plugin. + { + char attrList[1024]; + const auto listSize = ::listxattr(syncRoot.toUtf8().constData(), attrList, sizeof(attrList), 0); + if (listSize > 0) { + // Check if any of the xattrs look like openvfs markers. + const char *ptr = attrList; + const char *end = attrList + listSize; + bool xattrPlaceholderDetected = false; + while (ptr < end) { + const auto attrName = QString::fromUtf8(ptr); + if (attrName.contains(QLatin1String("openvfs"), Qt::CaseInsensitive) + || attrName.contains(QLatin1String("opencloud"), Qt::CaseInsensitive)) { + xattrPlaceholderDetected = true; + break; + } + ptr += strlen(ptr) + 1; + } + if (xattrPlaceholderDetected) { + qCWarning(lcVfsNSFP) << "Existing xattr placeholders detected — manual resync may be required after switching to NSFileProvider mode"; + } + } + } + + // Instantiate the domain manager if not already present + if (!_domainManager) { + _domainManager = std::make_unique(); + } + + // Derive a stable domain identifier from account UUID + space ID. + // Format: "opencloud-{accountUUID}-{spaceId}" (braces stripped from UUID). + const auto accountUuid = params.account->uuid().toString(QUuid::WithoutBraces); + const auto spaceId = params.spaceId(); + _domainId = QStringLiteral("opencloud-%1-%2").arg(accountUuid, spaceId); + + // Use the folder display name for the Finder sidebar + const auto displayName = params.folderDisplayName(); + + qCInfo(lcVfsNSFP) << "Domain identifier:" << _domainId << "displayName:" << displayName; + + // Register the domain asynchronously. Bridge result back to Qt thread. + QPointer self(this); + + // Connect to credential updates BEFORE the async domain registration so we + // never miss the fetched() signal (it may fire while addDomain is in progress). + QObject::connect(params.account->credentials(), &AbstractCredentials::fetched, + this, [self]() { + if (self) { + qCInfo(lcVfsNSFP) << "Credentials fetched — updating extension config"; + syncConfigToSharedContainer(self->params()); + } + }); + + _domainManager->addDomain(_domainId, displayName, [self](const QString &errorMessage) { + if (!self) { + return; + } + + if (errorMessage.isEmpty()) { + QMetaObject::invokeMethod(self, [self]() { + if (self) { + qCInfo(lcVfsNSFP) << "NSFileProvider domain registered successfully"; + + // Start the XPC handler so the extension can reach us. + self->_xpcHandler = std::make_unique(self, self); + self->_xpcHandler->startListener(); + + // Write initial file metadata to the shared container + // so the extension can enumerate items immediately. + syncMetadataToSharedContainer(self->params().journal); + + // Write WebDAV URL + access token so extension can download directly. + // The fetched() signal connection was already established before + // addDomain to avoid race conditions. + syncConfigToSharedContainer(self->params()); + + // Signal the enumerator so fileproviderd picks up the new items. + self->_domainManager->signalEnumerator(self->_domainId, QString()); + + // After each sync cycle, refresh the shared metadata plist and + // signal the extension to re-enumerate. This ensures deleted or + // changed files on the server are reflected in Finder. + auto *engine = self->params().syncEngine(); + if (engine) { + QObject::connect(engine, &SyncEngine::finished, self, [self](bool success) { + if (!self || !self->_domainManager || self->_domainId.isEmpty()) { + return; + } + qCInfo(lcVfsNSFP) << "Sync finished (success=" << success << ") — refreshing shared metadata"; + syncMetadataToSharedContainer(self->params().journal); + syncConfigToSharedContainer(self->params()); + // Signal both root and working set so Finder updates all views. + self->_domainManager->signalEnumerator(self->_domainId, QString()); + self->_domainManager->requestSystemEviction(self->_domainId); + }); + } + + // Start a periodic poll timer that requests a sync cycle + // so the Finder view stays current with server-side changes + // (deletions, renames, new files). Similar to how iCloud and + // OneDrive keep their views updated. + self->_pollTimer.setInterval(30 * 1000); // 30 seconds + QObject::connect(&self->_pollTimer, &QTimer::timeout, self, [self]() { + if (!self || !self->_domainManager || self->_domainId.isEmpty()) { + return; + } + // Keep the access token up to date for the extension. + syncConfigToSharedContainer(self->params()); + // Ask the sync scheduler to run a sync cycle. + Q_EMIT self->needSync(); + }); + self->_pollTimer.start(); + + Q_EMIT self->started(); + } + }, Qt::QueuedConnection); + } else { + QMetaObject::invokeMethod(self, [self, errorMessage]() { + if (self) { + qCWarning(lcVfsNSFP) << "Failed to register NSFileProvider domain:" << errorMessage; + Q_EMIT self->error(errorMessage); + } + }, Qt::QueuedConnection); + } + }); +} + +Result NsfpVfsPluginFactory::prepare(const QString &path, const QUuid &accountUuid) const +{ + Q_UNUSED(path) + Q_UNUSED(accountUuid) + // No special preparation needed yet + return {}; +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 9f1afbe50f..2a66330fb8 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -57,4 +57,10 @@ configure_file(test_journal.db "${PROJECT_BINARY_DIR}/bin/test_journal.db" COPYO opencloud_add_test(JobQueue) +# macOS NSFileProvider VFS tests — require macOS 12+ (Darwin 21.x) +if(APPLE AND CMAKE_SYSTEM_VERSION VERSION_GREATER_EQUAL "21.0") + opencloud_add_test(VfsNSFP) + opencloud_add_test(VfsNSFP_Integration) +endif() + add_subdirectory(modeltests) diff --git a/test/testvfsnsfp.cpp b/test/testvfsnsfp.cpp new file mode 100644 index 0000000000..d91557a4e8 --- /dev/null +++ b/test/testvfsnsfp.cpp @@ -0,0 +1,308 @@ +// Unit tests for VfsNSFP -- macOS NSFileProvider VFS plugin core methods. + +// Use __APPLE__ (compiler-defined) instead of Q_OS_MACOS (Qt-defined via qglobal.h) because +// this check appears before any Qt headers are included, so Q_OS_MACOS would never be defined. +#if defined(__APPLE__) + +#include "common/syncjournaldb.h" +#include "common/syncjournalfilerecord.h" +#include "syncengine.h" +#include "syncfileitem.h" +#include "vfs/vfs.h" + +#include "testutils/syncenginetestutils.h" +#include "testutils/testutils.h" + +#include +#include +#include + +using namespace OCC; +using namespace Qt::Literals::StringLiterals; + +class TestVfsNSFP : public QObject +{ + Q_OBJECT + +private: + /// Helper: create a Vfs instance via the plugin manager and wire it up with + /// a journal and temp directory. The domain registration will fail (no daemon), + /// but params() will be usable for method-level unit tests. + struct VfsTestFixture + { + QTemporaryDir tempDir; + SyncJournalDb journal; + OCC::TestUtils::TestUtilsPrivate::AccountStateRaii accountState; + std::unique_ptr syncEngine; + std::unique_ptr vfs; + bool valid = false; + + VfsTestFixture() + : journal(tempDir.path() + QStringLiteral("/sync.db")) + , accountState(OCC::TestUtils::createDummyAccount()) + { + if (!tempDir.isValid()) { + return; + } + + // Check if the NSFP plugin is available + if (!VfsPluginManager::instance().isVfsPluginAvailable(Vfs::Mode::MacOSNSFileProvider)) { + return; + } + + // SyncEngine needs a localPath ending in '/' + const auto localPath = tempDir.path() + QStringLiteral("/syncroot/"); + QDir().mkpath(localPath); + + auto acc = accountState->account(); + syncEngine = std::make_unique(acc, OCC::TestUtils::dummyDavUrl(), localPath, QStringLiteral("/"), &journal); + + // Create the VFS plugin instance via the plugin manager + vfs.reset(VfsPluginManager::instance().createVfsFromPlugin(Vfs::Mode::MacOSNSFileProvider).release()); + if (!vfs) { + return; + } + + // Build VfsSetupParams and call start(). startImpl() will attempt domain + // registration which will fail without a real daemon, but params() will + // be available for subsequent method calls. + VfsSetupParams params(acc, OCC::TestUtils::dummyDavUrl(), QStringLiteral("test-space-id"), QStringLiteral("Test Folder"), syncEngine.get()); + params.journal = &journal; + + // We expect the error signal (no real domain daemon), but that's fine. + vfs->start(params); + valid = true; + } + }; + +private Q_SLOTS: + + void testModeString() + { + // Verify modeFromString("nsfp") returns MacOSNSFileProvider + const auto mode = Vfs::modeFromString(QStringLiteral("nsfp")); + QVERIFY(static_cast(mode)); + QCOMPARE(*mode, Vfs::Mode::MacOSNSFileProvider); + + // Verify enumToString(MacOSNSFileProvider) returns "nsfp" + const auto str = Utility::enumToString(Vfs::Mode::MacOSNSFileProvider); + QCOMPARE(str, QStringLiteral("nsfp")); + } + + void testPluginConstruction() + { + // Verify VfsNSFP can be instantiated via plugin manager + if (!VfsPluginManager::instance().isVfsPluginAvailable(Vfs::Mode::MacOSNSFileProvider)) { + QSKIP("NSFP VFS plugin not available"); + } + + auto vfs = VfsPluginManager::instance().createVfsFromPlugin(Vfs::Mode::MacOSNSFileProvider); + QVERIFY(vfs); + QCOMPARE(vfs->mode(), Vfs::Mode::MacOSNSFileProvider); + QVERIFY(vfs->socketApiPinStateActionsShown()); + } + + void testPinStateRoundtrip() + { + VfsTestFixture fixture; + if (!fixture.valid) { + QSKIP("NSFP VFS plugin not available or fixture setup failed"); + } + auto *vfs = fixture.vfs.get(); + + // AlwaysLocal + vfs->setPinState(QStringLiteral("testfile.txt"), PinState::AlwaysLocal); + auto ps = vfs->pinState(QStringLiteral("testfile.txt")); + QVERIFY(static_cast(ps)); + QCOMPARE(*ps, PinState::AlwaysLocal); + + // OnlineOnly + vfs->setPinState(QStringLiteral("testfile2.txt"), PinState::OnlineOnly); + ps = vfs->pinState(QStringLiteral("testfile2.txt")); + QVERIFY(static_cast(ps)); + QCOMPARE(*ps, PinState::OnlineOnly); + + // Unspecified -- default when no explicit state is set + ps = vfs->pinState(QStringLiteral("unknown.txt")); + QVERIFY(static_cast(ps)); + QCOMPARE(*ps, PinState::Unspecified); + } + + void testIsDehydratedPlaceholder_noJournalRecord() + { + VfsTestFixture fixture; + if (!fixture.valid) { + QSKIP("NSFP VFS plugin not available or fixture setup failed"); + } + + const auto syncRoot = fixture.tempDir.path() + QStringLiteral("/syncroot/"); + const auto filePath = syncRoot + QStringLiteral("nonexistent.txt"); + QVERIFY(!fixture.vfs->isDehydratedPlaceholder(filePath)); + } + + void testIsDehydratedPlaceholder_virtualFileRecord() + { + VfsTestFixture fixture; + if (!fixture.valid) { + QSKIP("NSFP VFS plugin not available or fixture setup failed"); + } + + // Insert a virtual file record into the journal + auto item = OCC::TestUtils::dummyItem(QStringLiteral("cloud-only.txt")); + item._type = ItemTypeVirtualFile; + item._etag = QStringLiteral("etag1"); + item._fileId = "fileid1"; + const auto record = SyncJournalFileRecord::fromSyncFileItem(item); + QVERIFY(fixture.journal.setFileRecord(record)); + + const auto syncRoot = fixture.tempDir.path() + QStringLiteral("/syncroot/"); + const auto filePath = syncRoot + QStringLiteral("cloud-only.txt"); + QVERIFY(fixture.vfs->isDehydratedPlaceholder(filePath)); + } + + void testIsDehydratedPlaceholder_localFileRecord() + { + VfsTestFixture fixture; + if (!fixture.valid) { + QSKIP("NSFP VFS plugin not available or fixture setup failed"); + } + + auto item = OCC::TestUtils::dummyItem(QStringLiteral("local-file.txt")); + item._type = ItemTypeFile; + item._etag = QStringLiteral("etag2"); + item._fileId = "fileid2"; + const auto record = SyncJournalFileRecord::fromSyncFileItem(item); + QVERIFY(fixture.journal.setFileRecord(record)); + + const auto syncRoot = fixture.tempDir.path() + QStringLiteral("/syncroot/"); + const auto filePath = syncRoot + QStringLiteral("local-file.txt"); + QVERIFY(!fixture.vfs->isDehydratedPlaceholder(filePath)); + } + + void testNeedsMetadataUpdate_differentEtag() + { + VfsTestFixture fixture; + if (!fixture.valid) { + QSKIP("NSFP VFS plugin not available or fixture setup failed"); + } + + // Insert a record with etag "old-etag" + auto item = OCC::TestUtils::dummyItem(QStringLiteral("meta-file.txt")); + item._etag = QStringLiteral("old-etag"); + item._fileId = "fileid3"; + item._modtime = 1000; + item._size = 500; + const auto record = SyncJournalFileRecord::fromSyncFileItem(item); + QVERIFY(fixture.journal.setFileRecord(record)); + + // Create a new item with different etag + auto newItem = OCC::TestUtils::dummyItem(QStringLiteral("meta-file.txt")); + newItem._etag = QStringLiteral("new-etag"); + newItem._fileId = "fileid3"; + newItem._modtime = 1000; + newItem._size = 500; + + QVERIFY(fixture.vfs->needsMetadataUpdate(newItem)); + } + + void testNeedsMetadataUpdate_sameEtag() + { + VfsTestFixture fixture; + if (!fixture.valid) { + QSKIP("NSFP VFS plugin not available or fixture setup failed"); + } + + auto item = OCC::TestUtils::dummyItem(QStringLiteral("same-file.txt")); + item._etag = QStringLiteral("same-etag"); + item._fileId = "fileid4"; + item._modtime = 1000; + item._size = 500; + const auto record = SyncJournalFileRecord::fromSyncFileItem(item); + QVERIFY(fixture.journal.setFileRecord(record)); + + // Query with same metadata + auto queryItem = OCC::TestUtils::dummyItem(QStringLiteral("same-file.txt")); + queryItem._etag = QStringLiteral("same-etag"); + queryItem._fileId = "fileid4"; + queryItem._modtime = 1000; + queryItem._size = 500; + + QVERIFY(!fixture.vfs->needsMetadataUpdate(queryItem)); + } + + void testDomainIdentifier() + { + // Verify the domain identifier derivation is stable: creating two fixtures + // with the same account UUID and space ID yields the same domain ID. + // Since VfsNSFP::domainIdentifier() is private, we verify indirectly that + // the VFS initializes correctly with a consistent mode. + VfsTestFixture fixture; + if (!fixture.valid) { + QSKIP("NSFP VFS plugin not available or fixture setup failed"); + } + + // Verify the VFS is in the correct mode after initialization + QCOMPARE(fixture.vfs->mode(), Vfs::Mode::MacOSNSFileProvider); + + // Create a second fixture with the same account and verify consistency + VfsTestFixture fixture2; + if (!fixture2.valid) { + QSKIP("Second fixture failed to initialize"); + } + QCOMPARE(fixture2.vfs->mode(), Vfs::Mode::MacOSNSFileProvider); + } + + void testVolumeCheck() + { + // Test that startImpl() on a non-existent path handles the failure gracefully. + if (!VfsPluginManager::instance().isVfsPluginAvailable(Vfs::Mode::MacOSNSFileProvider)) { + QSKIP("NSFP VFS plugin not available"); + } + + auto accountState = OCC::TestUtils::createDummyAccount(); + auto acc = accountState->account(); + + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + SyncJournalDb journal(tempDir.path() + QStringLiteral("/sync.db")); + + // Use a non-existent path as sync root + const auto nonExistentPath = tempDir.path() + QStringLiteral("/does-not-exist/syncroot/"); + + auto syncEngine = std::make_unique(acc, OCC::TestUtils::dummyDavUrl(), nonExistentPath, QStringLiteral("/"), &journal); + + auto vfs = VfsPluginManager::instance().createVfsFromPlugin(Vfs::Mode::MacOSNSFileProvider); + QVERIFY(vfs); + + QSignalSpy errorSpy(vfs.get(), &Vfs::error); + + VfsSetupParams params(acc, OCC::TestUtils::dummyDavUrl(), QStringLiteral("test-space-id"), QStringLiteral("Test Folder"), syncEngine.get()); + params.journal = &journal; + + vfs->start(params); + + // startImpl will fail the statfs call for non-existent path, but it + // just logs a warning and continues. The domain registration will also + // fail asynchronously. We verify the VFS was created without a crash. + QCOMPARE(vfs->mode(), Vfs::Mode::MacOSNSFileProvider); + + // Process pending events to allow async error signals to arrive. + QCoreApplication::processEvents(); + } +}; + +QTEST_GUILESS_MAIN(TestVfsNSFP) +#include "testvfsnsfp.moc" + +#else +// Non-macOS: provide an empty main so the build does not fail. +#include +class TestVfsNSFP : public QObject +{ + Q_OBJECT +private Q_SLOTS: + void testSkipped() { QSKIP("VfsNSFP tests are macOS-only"); } +}; +QTEST_GUILESS_MAIN(TestVfsNSFP) +#include "testvfsnsfp.moc" +#endif diff --git a/test/testvfsnsfp_integration.cpp b/test/testvfsnsfp_integration.cpp new file mode 100644 index 0000000000..f8f51dcb94 --- /dev/null +++ b/test/testvfsnsfp_integration.cpp @@ -0,0 +1,88 @@ +// Integration test stubs for VfsNSFP -- macOS NSFileProvider VFS plugin. +// +// These tests require a real macOS 12+ system with the NSFileProvider daemon +// running. They are stubs that document the expected integration test scenarios +// and ensure the test infrastructure is in place for future CI. + +// Use __APPLE__ (compiler-defined) instead of Q_OS_MACOS (Qt-defined via qglobal.h) because +// this check appears before any Qt headers are included, so Q_OS_MACOS would never be defined. +#if defined(__APPLE__) + +#include + +class TestVfsNSFPIntegration : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + + void testDomainRegistration() + { + QSKIP("Requires macOS 12+ with NSFileProvider daemon. " + "This test would register a domain via NsfpDomainManager::addDomain() " + "and verify it appears in [NSFileProviderManager getDomainsWithCompletionHandler:]."); + } + + void testPlaceholderAppearance() + { + QSKIP("Requires macOS 12+ with NSFileProvider daemon. " + "This test would call createPlaceholder() with a SyncFileItem and verify " + "the item appears as a cloud-only file in Finder via the File Provider framework."); + } + + void testHydrationFlow() + { + QSKIP("Requires macOS 12+ with NSFileProvider daemon. " + "This test would open a placeholder file and verify that the File Provider " + "extension receives a fetchContents request via XPC and the file becomes " + "available locally with its full contents."); + } + + void testEvictionFlow() + { + QSKIP("Requires macOS 12+ with NSFileProvider daemon. " + "This test would evict a hydrated file via NsfpDomainManager::evictItem() " + "and verify the file reverts to a dehydrated placeholder state, freeing " + "local disk space while keeping the cloud reference."); + } + + void testUploadFlow() + { + QSKIP("Requires macOS 12+ with NSFileProvider daemon. " + "This test would copy a new file into the domain folder and verify that " + "the sync engine picks it up, uploads it to the server, and the file is " + "subsequently eligible for eviction."); + } + + void testPinStateAlwaysLocal() + { + QSKIP("Requires macOS 12+ with NSFileProvider daemon. " + "This test would set a folder to PinState::AlwaysLocal and verify that " + "all child placeholder files are hydrated (downloaded) automatically, " + "ensuring the folder contents are always available offline."); + } + + void testMigrationFromXattr() + { + QSKIP("Requires macOS 12+ with NSFileProvider daemon. " + "This test would set up a sync folder with existing xattr-based VFS " + "placeholders, switch to NSFP mode, and verify that the migration " + "completes without data loss and all files are accessible."); + } +}; + +QTEST_GUILESS_MAIN(TestVfsNSFPIntegration) +#include "testvfsnsfp_integration.moc" + +#else +// Non-macOS: provide an empty main so the build does not fail. +#include +class TestVfsNSFPIntegration : public QObject +{ + Q_OBJECT +private Q_SLOTS: + void testSkipped() { QSKIP("VfsNSFP integration tests are macOS-only"); } +}; +QTEST_GUILESS_MAIN(TestVfsNSFPIntegration) +#include "testvfsnsfp_integration.moc" +#endif From daea604773a2088fac02a2cd940d386dc9882537 Mon Sep 17 00:00:00 2001 From: Justin Mester Date: Wed, 18 Mar 2026 09:59:47 +0100 Subject: [PATCH 2/5] Make Apple Development Team ID configurable via CMake Replace hardcoded Team ID (P4D766R5ZA) with the CMake cache variable APPLE_DEVELOPMENT_TEAM so any developer can build and sign the app with their own Apple Developer account. The App Group identifier is now derived at configure time as ${APPLE_DEVELOPMENT_TEAM}.${APPLICATION_REV_DOMAIN} and passed to: - Entitlements (via configure_file) - Info.plist (via CMake variable expansion) - ObjC sources (via compile definition APP_GROUP_IDENTIFIER) - Xcode build attributes (DEVELOPMENT_TEAM) Usage: cmake -DAPPLE_DEVELOPMENT_TEAM=XXXXXXXXXX .. --- CMakeLists.txt | 1 + src/extensions/fileprovider/CMakeLists.txt | 17 +++++++++++++++-- .../fileprovider/FileProviderXPCService.h | 6 +++++- src/extensions/fileprovider/Info.plist.in | 2 +- .../OpenCloudFileProvider.entitlements | 2 +- .../OpenCloudFileProvider.entitlements.in | 7 ++++--- src/gui/CMakeLists.txt | 2 +- src/plugins/vfs/nsfp/CMakeLists.txt | 6 ++++++ 8 files changed, 34 insertions(+), 9 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 360d73f253..b3a1e7cdab 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -115,6 +115,7 @@ endif() if(APPLE) set( SOCKETAPI_TEAM_IDENTIFIER_PREFIX "" CACHE STRING "SocketApi prefix (including a following dot) that must match the codesign key's TeamIdentifier/Organizational Unit" ) + set( APPLE_DEVELOPMENT_TEAM "" CACHE STRING "Apple Development Team ID used for code signing and App Group identifiers" ) endif() diff --git a/src/extensions/fileprovider/CMakeLists.txt b/src/extensions/fileprovider/CMakeLists.txt index 912441dde9..56638f4b6c 100644 --- a/src/extensions/fileprovider/CMakeLists.txt +++ b/src/extensions/fileprovider/CMakeLists.txt @@ -5,6 +5,14 @@ if(APPLE) enable_language(OBJCXX) + # Generate entitlements with the configured App Group identifier. + set(APP_GROUP_IDENTIFIER "${APPLE_DEVELOPMENT_TEAM}.${APPLICATION_REV_DOMAIN}") + configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/OpenCloudFileProvider.entitlements" + "${CMAKE_CURRENT_BINARY_DIR}/OpenCloudFileProvider.entitlements" + @ONLY + ) + set(FILEPROVIDER_EXTENSION_SOURCES OpenCloudFileProviderExtension.mm FileProviderXPCService.mm @@ -38,13 +46,18 @@ if(APPLE) MACOSX_BUNDLE_BUNDLE_VERSION "${MIRALL_VERSION_FULL}" MACOSX_BUNDLE_SHORT_VERSION_STRING "${MIRALL_VERSION}" MACOSX_BUNDLE_GUI_IDENTIFIER "${APPLICATION_REV_DOMAIN}.fileprovider" - XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_CURRENT_SOURCE_DIR}/OpenCloudFileProvider.entitlements" + XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_CURRENT_BINARY_DIR}/OpenCloudFileProvider.entitlements" XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "$(CODE_SIGN_IDENTITY)" - XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "P4D766R5ZA" + XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "${APPLE_DEVELOPMENT_TEAM}" XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${APPLICATION_REV_DOMAIN}.fileprovider" XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME YES ) + # Pass the App Group identifier to source code. + target_compile_definitions(OpenCloudFileProviderExtension PRIVATE + APP_GROUP_IDENTIFIER="${APP_GROUP_IDENTIFIER}" + ) + # App Extension compile flag: restricts API surface to extension-safe APIs target_compile_options(OpenCloudFileProviderExtension PRIVATE -fapplication-extension diff --git a/src/extensions/fileprovider/FileProviderXPCService.h b/src/extensions/fileprovider/FileProviderXPCService.h index f00a61fd98..1de96b55bc 100644 --- a/src/extensions/fileprovider/FileProviderXPCService.h +++ b/src/extensions/fileprovider/FileProviderXPCService.h @@ -14,7 +14,11 @@ NS_ASSUME_NONNULL_BEGIN static NSString *const kOpenCloudXPCServiceName = @"eu.opencloud.desktop.fileprovider.xpc"; /// App Group identifier used to share data between the main app and extension. -static NSString *const kOpenCloudAppGroupIdentifier = @"P4D766R5ZA.eu.opencloud.desktop"; +/// Set via -DAPP_GROUP_IDENTIFIER=... compile definition from CMake. +#ifndef APP_GROUP_IDENTIFIER +#define APP_GROUP_IDENTIFIER "eu.opencloud.desktop" +#endif +static NSString *const kOpenCloudAppGroupIdentifier = @APP_GROUP_IDENTIFIER; /// Filename for the XPC listener endpoint stored in the App Group shared container. /// The main app writes this file; the extension reads it to establish the XPC connection. diff --git a/src/extensions/fileprovider/Info.plist.in b/src/extensions/fileprovider/Info.plist.in index c4cc724488..d275c78f2a 100644 --- a/src/extensions/fileprovider/Info.plist.in +++ b/src/extensions/fileprovider/Info.plist.in @@ -27,7 +27,7 @@ NSExtension NSExtensionFileProviderDocumentGroup - P4D766R5ZA.eu.opencloud.desktop + ${APPLE_DEVELOPMENT_TEAM}.${APPLICATION_REV_DOMAIN} NSExtensionFileProviderSupportsEnumeration NSExtensionPointIdentifier diff --git a/src/extensions/fileprovider/OpenCloudFileProvider.entitlements b/src/extensions/fileprovider/OpenCloudFileProvider.entitlements index e3d7aa8577..a34c429901 100644 --- a/src/extensions/fileprovider/OpenCloudFileProvider.entitlements +++ b/src/extensions/fileprovider/OpenCloudFileProvider.entitlements @@ -6,7 +6,7 @@ com.apple.security.application-groups - P4D766R5ZA.eu.opencloud.desktop + @APP_GROUP_IDENTIFIER@ com.apple.security.network.client diff --git a/src/extensions/fileprovider/OpenCloudFileProvider.entitlements.in b/src/extensions/fileprovider/OpenCloudFileProvider.entitlements.in index dc0aed2f49..ea8c34f0f0 100644 --- a/src/extensions/fileprovider/OpenCloudFileProvider.entitlements.in +++ b/src/extensions/fileprovider/OpenCloudFileProvider.entitlements.in @@ -1,20 +1,21 @@ com.apple.security.application-groups - 283S23Y8TH.eu.opencloud.desktop + ${APPLE_DEVELOPMENT_TEAM}.${APPLICATION_REV_DOMAIN} com.apple.developer.fileprovider.server-capability keychain-access-groups - $(AppIdentifierPrefix)eu.opencloud.desktop + $(AppIdentifierPrefix)${APPLICATION_REV_DOMAIN} diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index ae84043eab..a6b0417b02 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -212,7 +212,7 @@ else() MACOSX_BUNDLE_INFO_PLIST ${CMAKE_CURRENT_SOURCE_DIR}/MacOSXBundleInfo.plist XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_SOURCE_DIR}/src/OpenCloud.entitlements" XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "$(CODE_SIGN_IDENTITY)" - XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "P4D766R5ZA" + XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "${APPLE_DEVELOPMENT_TEAM}" XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME NO ) endif() diff --git a/src/plugins/vfs/nsfp/CMakeLists.txt b/src/plugins/vfs/nsfp/CMakeLists.txt index 400185b6a5..502ecc9dc9 100644 --- a/src/plugins/vfs/nsfp/CMakeLists.txt +++ b/src/plugins/vfs/nsfp/CMakeLists.txt @@ -2,6 +2,8 @@ # Only compiled on Apple platforms (macOS 12+). if(APPLE) + set(APP_GROUP_IDENTIFIER "${APPLE_DEVELOPMENT_TEAM}.${APPLICATION_REV_DOMAIN}") + add_vfs_plugin(NAME nsfp SRC nsfpdomainmanager.mm @@ -13,6 +15,10 @@ if(APPLE) "-lsqlite3" ) + target_compile_definitions(vfs_nsfp PRIVATE + APP_GROUP_IDENTIFIER="${APP_GROUP_IDENTIFIER}" + ) + # Enable ARC for all Objective-C++ sources in this plugin. target_compile_options(vfs_nsfp PRIVATE -fobjc-arc) From 99e16adb9fc0a6430a2c6f12296111c99e944933 Mon Sep 17 00:00:00 2001 From: Justin Mester Date: Thu, 26 Mar 2026 10:39:37 +0100 Subject: [PATCH 3/5] Fix NSFileProvider extension for reliable file operations Implement direct WebDAV operations (upload, delete, move) in the FileProvider extension to eliminate XPC dependency. Fix multi-account and multi-space support by using per-domain config and metadata plists. Fix deletion detection by using per-domain prevFileIds caches to prevent race conditions between concurrent extension processes. Add content staging for uploads, configurable App Group entitlements for both main app and extension, and automatic legacy file cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/OpenCloud.entitlements | 20 + src/extensions/fileprovider/CMakeLists.txt | 4 +- .../fileprovider/FileProviderEnumerator.h | 4 +- .../fileprovider/FileProviderEnumerator.mm | 38 +- .../fileprovider/FileProviderItem.mm | 1 + .../OpenCloudFileProviderExtension.mm | 538 ++++++++++++++---- src/gui/CMakeLists.txt | 9 +- src/plugins/vfs/nsfp/nsfpdomainmanager.h | 4 + src/plugins/vfs/nsfp/nsfpdomainmanager.mm | 29 + src/plugins/vfs/nsfp/vfs_nsfp.mm | 208 ++++++- 10 files changed, 707 insertions(+), 148 deletions(-) create mode 100644 src/OpenCloud.entitlements diff --git a/src/OpenCloud.entitlements b/src/OpenCloud.entitlements new file mode 100644 index 0000000000..25a8ac63dc --- /dev/null +++ b/src/OpenCloud.entitlements @@ -0,0 +1,20 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + @APP_GROUP_IDENTIFIER@ + + com.apple.security.cs.disable-library-validation + + com.apple.security.cs.jit + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + + diff --git a/src/extensions/fileprovider/CMakeLists.txt b/src/extensions/fileprovider/CMakeLists.txt index 56638f4b6c..c3d6be3732 100644 --- a/src/extensions/fileprovider/CMakeLists.txt +++ b/src/extensions/fileprovider/CMakeLists.txt @@ -103,10 +103,10 @@ if(APPLE) if(DEFINED CODESIGN_IDENTITY AND NOT CODESIGN_IDENTITY STREQUAL "") add_custom_command(TARGET OpenCloudFileProviderExtension POST_BUILD COMMAND codesign --force --sign "${CODESIGN_IDENTITY}" - --entitlements "${CMAKE_CURRENT_SOURCE_DIR}/OpenCloudFileProvider.entitlements" + --entitlements "${CMAKE_CURRENT_BINARY_DIR}/OpenCloudFileProvider.entitlements" "$" COMMAND codesign --force --sign "${CODESIGN_IDENTITY}" - --entitlements "${CMAKE_CURRENT_SOURCE_DIR}/OpenCloudFileProvider.entitlements" + --entitlements "${CMAKE_CURRENT_BINARY_DIR}/OpenCloudFileProvider.entitlements" "$/../PlugIns/OpenCloudFileProviderExtension.appex" COMMENT "Code-signing FileProviderExtension with sandbox entitlements" ) diff --git a/src/extensions/fileprovider/FileProviderEnumerator.h b/src/extensions/fileprovider/FileProviderEnumerator.h index 6932d0d7f6..4b274e33bc 100644 --- a/src/extensions/fileprovider/FileProviderEnumerator.h +++ b/src/extensions/fileprovider/FileProviderEnumerator.h @@ -19,7 +19,9 @@ API_AVAILABLE(macos(12.0)) /// @param containerId The identifier of the container to enumerate /// (e.g. root container or a folder's file ID). /// @param service The XPC service used to communicate with the main app. -- (instancetype)initWithContainerIdentifier:(NSFileProviderItemIdentifier)containerId xpcService:(FileProviderXPCService *)service; +- (instancetype)initWithContainerIdentifier:(NSFileProviderItemIdentifier)containerId + xpcService:(FileProviderXPCService *)service + domain:(NSFileProviderDomain *)domain; @end diff --git a/src/extensions/fileprovider/FileProviderEnumerator.mm b/src/extensions/fileprovider/FileProviderEnumerator.mm index 9a246dda44..43030b9571 100644 --- a/src/extensions/fileprovider/FileProviderEnumerator.mm +++ b/src/extensions/fileprovider/FileProviderEnumerator.mm @@ -35,8 +35,8 @@ static void appendTrace(NSString *line) { } /// Reads the shared metadata plist from the App Group container. -/// Returns an array of NSDictionary items, or nil if unavailable. -static NSArray *readSharedMetadata(void) { +/// Uses per-domain file if available, falls back to legacy global file. +static NSArray *readSharedMetadata(NSFileProviderDomain *domain) { NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; if (!containerURL) { @@ -44,7 +44,12 @@ static void appendTrace(NSString *line) { return nil; } - NSURL *metadataURL = [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; + // Try per-domain file first, fall back to legacy. + NSString *perDomainName = [NSString stringWithFormat:@"fileprovider_items_%@.plist", domain.identifier]; + NSURL *perDomainURL = [containerURL URLByAppendingPathComponent:perDomainName]; + NSURL *metadataURL = [[NSFileManager defaultManager] fileExistsAtPath:perDomainURL.path] + ? perDomainURL + : [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; NSData *data = [NSData dataWithContentsOfURL:metadataURL]; if (!data) { os_log_error(enumeratorLog(), "Shared metadata file not found at: %{public}@", metadataURL.path); @@ -71,18 +76,21 @@ static void appendTrace(NSString *line) { @implementation FileProviderEnumerator { NSFileProviderItemIdentifier _containerId; FileProviderXPCService *_xpcService; + NSFileProviderDomain *_domain; BOOL _invalidated; } - (instancetype)initWithContainerIdentifier:(NSFileProviderItemIdentifier)containerId - xpcService:(FileProviderXPCService *)service { + xpcService:(FileProviderXPCService *)service + domain:(NSFileProviderDomain *)domain { self = [super init]; if (self) { _containerId = [containerId copy]; _xpcService = service; + _domain = domain; _invalidated = NO; - os_log_fault(enumeratorLog(), ">>> Enumerator CREATED for container: %{public}@", containerId); + os_log_debug(enumeratorLog(), "Enumerator CREATED for container: %{public}@", containerId); } return self; } @@ -92,7 +100,7 @@ - (instancetype)initWithContainerIdentifier:(NSFileProviderItemIdentifier)contai - (void)enumerateItemsForObserver:(id)observer startingAtPage:(NSFileProviderPage)page { // Use fault-level logging to ensure it's always persisted - os_log_fault(enumeratorLog(), ">>> enumerateItems CALLED container=%{public}@ invalidated=%d", _containerId, _invalidated); + os_log_debug(enumeratorLog(), "enumerateItems CALLED container=%{public}@ invalidated=%d", _containerId, _invalidated); appendTrace([NSString stringWithFormat:@"[%@] enumerateItems container=%@ invalidated=%d\n", [NSDate date], _containerId, _invalidated]); @@ -108,7 +116,7 @@ - (void)enumerateItemsForObserver:(id)observe os_log_info(enumeratorLog(), "enumerateItems container=%{public}@", _containerId); // Read file metadata from the App Group shared container. - NSArray *allItems = readSharedMetadata(); + NSArray *allItems = readSharedMetadata(_domain); if (!allItems) { os_log_error(enumeratorLog(), "No shared metadata available — main app may not be running"); [observer didEnumerateItems:@[]]; @@ -173,7 +181,7 @@ - (void)enumerateItemsForObserver:(id)observe - (void)enumerateChangesForObserver:(id)observer fromSyncAnchor:(NSFileProviderSyncAnchor)anchor { - os_log_fault(enumeratorLog(), ">>> enumerateChanges CALLED container=%{public}@", _containerId); + os_log_debug(enumeratorLog(), "enumerateChanges CALLED container=%{public}@", _containerId); { NSString *inAnchorStr = anchor ? [[NSString alloc] initWithData:anchor encoding:NSUTF8StringEncoding] : @"(nil)"; @@ -184,7 +192,7 @@ - (void)enumerateChangesForObserver:(id)observer os_log_info(enumeratorLog(), "enumerateChanges container=%{public}@", _containerId); // Read current metadata to build a content-based sync anchor. - NSArray *allItems = readSharedMetadata(); + NSArray *allItems = readSharedMetadata(_domain); NSString *currentAnchorString = @"empty"; if (allItems) { // Use item count + latest modtime as a simple content anchor. @@ -229,6 +237,11 @@ - (void)enumerateChangesForObserver:(id)observer break; } } + if (!targetParentPath) { + os_log_error(enumeratorLog(), "enumerateChanges: container %{public}@ not found in metadata — skipping", _containerId); + [observer finishEnumeratingChangesUpToSyncAnchor:currentAnchor moreComing:NO]; + return; + } } for (NSDictionary *dict in allItems) { @@ -250,7 +263,10 @@ - (void)enumerateChangesForObserver:(id)observer // Detect deleted items by comparing current fileIds with the set from the // previous enumerateChanges call. Report deletions so fileproviderd removes // them from Finder. - NSString *cacheKey = [NSString stringWithFormat:@"prevFileIds_%@", + // Cache key must include the domain identifier so that multiple + // domains (spaces/accounts) don't overwrite each other's caches. + NSString *cacheKey = [NSString stringWithFormat:@"prevFileIds_%@_%@", + _domain.identifier, [_containerId stringByReplacingOccurrencesOfString:@"/" withString:@"_"]]; NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; @@ -278,7 +294,7 @@ - (void)enumerateChangesForObserver:(id)observer - (void)currentSyncAnchorWithCompletionHandler:(void (^)(NSFileProviderSyncAnchor _Nullable))completionHandler { // Build a content-based anchor from the shared metadata. - NSArray *allItems = readSharedMetadata(); + NSArray *allItems = readSharedMetadata(_domain); NSString *anchorString = @"empty"; if (allItems) { int64_t latestModtime = 0; diff --git a/src/extensions/fileprovider/FileProviderItem.mm b/src/extensions/fileprovider/FileProviderItem.mm index 842ed60af6..15020f76a0 100644 --- a/src/extensions/fileprovider/FileProviderItem.mm +++ b/src/extensions/fileprovider/FileProviderItem.mm @@ -179,6 +179,7 @@ - (NSFileProviderItemCapabilities)capabilities { return NSFileProviderItemCapabilitiesAllowsReading | NSFileProviderItemCapabilitiesAllowsWriting | NSFileProviderItemCapabilitiesAllowsRenaming + | NSFileProviderItemCapabilitiesAllowsReparenting | NSFileProviderItemCapabilitiesAllowsDeleting | NSFileProviderItemCapabilitiesAllowsEvicting; } diff --git a/src/extensions/fileprovider/OpenCloudFileProviderExtension.mm b/src/extensions/fileprovider/OpenCloudFileProviderExtension.mm index 14f2cb30ec..502e3bf2ab 100644 --- a/src/extensions/fileprovider/OpenCloudFileProviderExtension.mm +++ b/src/extensions/fileprovider/OpenCloudFileProviderExtension.mm @@ -74,6 +74,28 @@ static void appendTrace(NSString *line) { return configUnavailableError(@"Hintergrund-Synchronisation nicht verfügbar"); } +/// Returns the per-domain config plist filename, falling back to the legacy +/// global filename if the per-domain file does not exist yet. +static NSString *configPlistName(NSFileProviderDomain *domain, NSURL *containerURL) { + NSString *perDomain = [NSString stringWithFormat:@"fileprovider_config_%@.plist", domain.identifier]; + if ([[NSFileManager defaultManager] fileExistsAtPath: + [[containerURL URLByAppendingPathComponent:perDomain] path]]) { + return perDomain; + } + return @"fileprovider_config.plist"; // legacy fallback +} + +/// Returns the per-domain items plist filename, falling back to the legacy +/// global filename if the per-domain file does not exist yet. +static NSString *itemsPlistName(NSFileProviderDomain *domain, NSURL *containerURL) { + NSString *perDomain = [NSString stringWithFormat:@"fileprovider_items_%@.plist", domain.identifier]; + if ([[NSFileManager defaultManager] fileExistsAtPath: + [[containerURL URLByAppendingPathComponent:perDomain] path]]) { + return perDomain; + } + return @"fileprovider_items.plist"; // legacy fallback +} + #pragma mark - Default Item Capabilities /// Returns default NSFileProviderItemCapabilities for items served by this extension. @@ -111,7 +133,7 @@ - (instancetype)initWithDomain:(NSFileProviderDomain *)domain { _domain = domain; // Multiple trace mechanisms to diagnose NSLog(@">>> EXTENSION INIT domain=%@", domain.identifier); - os_log_fault(extensionLog(), ">>> EXTENSION INIT domain=%{public}@", domain.identifier); + os_log_debug(extensionLog(), "EXTENSION INIT domain=%{public}@", domain.identifier); // Try writing to a KNOWN writable location NSString *homeDir = NSHomeDirectory(); @@ -165,7 +187,7 @@ - (NSProgress *)itemForIdentifier:(NSFileProviderItemIdentifier)identifier NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; if (containerURL) { - NSURL *metadataURL = [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; NSData *data = [NSData dataWithContentsOfURL:metadataURL]; if (data) { NSArray *items = [NSPropertyListSerialization propertyListWithData:data @@ -228,7 +250,7 @@ - (NSProgress *)fetchContentsForItemWithIdentifier:(NSFileProviderItemIdentifier } // Read server config (davUrl + accessToken). - NSURL *configURL = [containerURL URLByAppendingPathComponent:@"fileprovider_config.plist"]; + NSURL *configURL = [containerURL URLByAppendingPathComponent:configPlistName(self->_domain, containerURL)]; NSData *configData = [NSData dataWithContentsOfURL:configURL]; if (!configData) { os_log_error(extensionLog(), "fetchContents: config plist not found at %{public}@", configURL.path); @@ -239,25 +261,17 @@ - (NSProgress *)fetchContentsForItemWithIdentifier:(NSFileProviderItemIdentifier NSDictionary *config = [NSPropertyListSerialization propertyListWithData:configData options:NSPropertyListImmutable format:nil error:nil]; - NSString *davUrl = config[@"davUrl"]; + NSString *davUrl = config[@"davUrl"]; // fallback NSString *accessToken = config[@"accessToken"]; - if (!davUrl || davUrl.length == 0) { - os_log_error(extensionLog(), "fetchContents: no davUrl in config"); - [self _completeHydrationForFileId:fileId url:nil item:nil - error:configUnavailableError(@"Server-URL nicht konfiguriert")]; - return; - } if (!accessToken || accessToken.length == 0) { os_log_error(extensionLog(), "fetchContents: no access token — app may still be starting"); - // Use a transient error so fileproviderd retries later instead of - // removing the item from Finder (which NotAuthenticated would do). [self _completeHydrationForFileId:fileId url:nil item:nil error:configUnavailableError(@"Anmeldung wird vorbereitet — bitte kurz warten")]; return; } // Look up the file path from the items plist. - NSURL *metadataURL = [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; NSData *metaData = [NSData dataWithContentsOfURL:metadataURL]; NSString *filePath = nil; NSDictionary *itemDict = nil; @@ -284,6 +298,17 @@ - (NSProgress *)fetchContentsForItemWithIdentifier:(NSFileProviderItemIdentifier return; } + // Use per-item davUrl if available (correct space), fall back to global config. + if (itemDict[@"davUrl"]) { + davUrl = itemDict[@"davUrl"]; + } + if (!davUrl || davUrl.length == 0) { + os_log_error(extensionLog(), "fetchContents: no davUrl for %{public}@", fileId); + [self _completeHydrationForFileId:fileId url:nil item:nil + error:configUnavailableError(@"Server-URL nicht konfiguriert")]; + return; + } + // Build the WebDAV download URL: davUrl + "/" + filePath NSString *davBase = [davUrl hasSuffix:@"/"] ? [davUrl substringToIndex:davUrl.length - 1] : davUrl; NSString *encodedPath = [filePath stringByAddingPercentEncodingWithAllowedCharacters: @@ -334,11 +359,14 @@ - (NSProgress *)fetchContentsForItemWithIdentifier:(NSFileProviderItemIdentifier @"Die Anmeldung ist abgelaufen. Bitte melde dich in der OpenCloud App erneut an.", @"FileProvider HTTP 401/403"); } else if (http.statusCode == 404) { - fpCode = NSFileProviderErrorNoSuchItem; + // Use a transient error so fileproviderd retries later + // instead of permanently removing the item from Finder. + // The item will be cleaned up by the next sync cycle if + // it was truly deleted on the server. + fpCode = NSFileProviderErrorServerUnreachable; userMessage = NSLocalizedString( @"Diese Datei wurde auf dem Server nicht gefunden. Sie wurde möglicherweise gelöscht oder verschoben.", @"FileProvider HTTP 404"); - [self _removeStaleItemFromPlist:fileId]; } else if (http.statusCode >= 500) { fpCode = NSFileProviderErrorServerUnreachable; userMessage = [NSString stringWithFormat: @@ -421,7 +449,7 @@ - (void)_removeStaleItemFromPlist:(NSString *)fileId { containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; if (!containerURL) return; - NSURL *metadataURL = [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; NSData *data = [NSData dataWithContentsOfURL:metadataURL]; if (!data) return; @@ -456,6 +484,113 @@ - (void)_removeStaleItemFromPlist:(NSString *)fileId { } } +/// Updates an existing item's path and parent in the plist after a MOVE. +/// Does NOT set extensionCreated, so the item behaves like a journal-sourced +/// entry and gets cleaned up normally when deleted on the server. +- (void)_updateItemPathInPlist:(NSString *)fileId + newPath:(NSString *)newPath + newParentId:(NSString *)newParentId + newParentPath:(NSString *)newParentPath + davUrl:(NSString *)davUrl { + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) return; + + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; + NSData *data = [NSData dataWithContentsOfURL:metadataURL]; + if (!data) return; + + NSArray *items = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListImmutable + format:nil error:nil]; + if (![items isKindOfClass:[NSArray class]]) return; + + NSMutableArray *mutableItems = [items mutableCopy]; + for (NSUInteger i = 0; i < mutableItems.count; i++) { + NSDictionary *item = mutableItems[i]; + if ([item[@"fileId"] isEqualToString:fileId]) { + NSMutableDictionary *updated = [item mutableCopy]; + updated[@"path"] = newPath; + updated[@"parentId"] = newParentId; + updated[@"parentPath"] = newParentPath; + if (davUrl) updated[@"davUrl"] = davUrl; + // Remove extensionCreated but add movedAt timestamp. + // The merge logic preserves moved items briefly so the + // enumerator can register them in prevFileIds. Without + // this, server-side deletions of moved files are never + // detected because the item was never in prevFileIds + // for the new parent container. + [updated removeObjectForKey:@"extensionCreated"]; + [updated removeObjectForKey:@"extensionCreatedAt"]; + updated[@"movedAt"] = @((int64_t)[[NSDate date] timeIntervalSince1970]); + [mutableItems replaceObjectAtIndex:i withObject:updated]; + break; + } + } + + NSData *newData = [NSPropertyListSerialization dataWithPropertyList:mutableItems + format:NSPropertyListBinaryFormat_v1_0 + options:0 error:nil]; + if (newData) { + [newData writeToURL:metadataURL atomically:YES]; + os_log_info(extensionLog(), "Updated item %{public}@ path to %{public}@ in plist", fileId, newPath); + } +} + +/// Appends a newly created item to the shared fileprovider_items.plist so +/// subsequent enumerations include it. Without this, items created via +/// createItemBasedOnTemplate would disappear from Finder on the next +/// enumeration cycle because the enumerator only reports plist contents. +- (void)_appendItemToPlist:(NSDictionary *)itemDict { + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) return; + + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; + NSMutableArray *items = nil; + NSData *data = [NSData dataWithContentsOfURL:metadataURL]; + if (data) { + NSArray *existing = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListImmutable + format:nil error:nil]; + items = existing ? [existing mutableCopy] : [NSMutableArray array]; + } else { + items = [NSMutableArray array]; + } + + // Mark the item as extension-created so syncMetadataToSharedContainer + // in the main app preserves it until the sync engine discovers it. + // Items without this flag are treated as journal-sourced and will be + // removed when they disappear from the journal (e.g. server-side delete). + NSMutableDictionary *markedItem = [itemDict mutableCopy]; + markedItem[@"extensionCreated"] = @YES; + markedItem[@"extensionCreatedAt"] = @((int64_t)[[NSDate date] timeIntervalSince1970]); + + // Replace any existing entry with the same fileId to avoid duplicates. + NSString *newFileId = markedItem[@"fileId"]; + NSUInteger existingIndex = NSNotFound; + for (NSUInteger i = 0; i < items.count; i++) { + if ([items[i][@"fileId"] isEqualToString:newFileId]) { + existingIndex = i; + break; + } + } + if (existingIndex != NSNotFound) { + [items replaceObjectAtIndex:existingIndex withObject:markedItem]; + } else { + [items addObject:markedItem]; + } + + NSData *newData = [NSPropertyListSerialization dataWithPropertyList:items + format:NSPropertyListBinaryFormat_v1_0 + options:0 error:nil]; + if (newData) { + [newData writeToURL:metadataURL atomically:YES]; + os_log_info(extensionLog(), "Appended item %{public}@ to shared plist (total: %lu)", + newFileId, (unsigned long)items.count); + } +} + #pragma mark - NSFileProviderReplicatedExtension (Create) - (NSProgress *)createItemBasedOnTemplate:(id)itemTemplate @@ -485,7 +620,7 @@ - (NSProgress *)createItemBasedOnTemplate:(id)itemTemplate NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; if (containerURL) { - NSURL *metadataURL = [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; NSData *data = [NSData dataWithContentsOfURL:metadataURL]; if (data) { NSArray *items = [NSPropertyListSerialization propertyListWithData:data @@ -498,7 +633,7 @@ - (NSProgress *)createItemBasedOnTemplate:(id)itemTemplate if ([filename isEqualToString:templateName] && [parentId isEqualToString:templateParent]) { FileProviderItem *item = [[FileProviderItem alloc] initWithDictionary:dict]; - os_log_fault(extensionLog(), "createItem: PLIST MATCH %{public}@ id=%{public}@", + os_log_info(extensionLog(), "createItem: PLIST MATCH %{public}@ id=%{public}@", filename, item.itemIdentifier); completionHandler(item, NSFileProviderItemFields(0), NO, nil); return progress; @@ -535,37 +670,48 @@ - (NSProgress *)createItemBasedOnTemplate:(id)itemTemplate return progress; } - // Read config - NSURL *configURL = [containerURL URLByAppendingPathComponent:@"fileprovider_config.plist"]; + // Read access token from global config. + NSURL *configURL = [containerURL URLByAppendingPathComponent:configPlistName(self->_domain, containerURL)]; NSData *configData = [NSData dataWithContentsOfURL:configURL]; NSDictionary *config = configData ? [NSPropertyListSerialization propertyListWithData:configData options:NSPropertyListImmutable format:nil error:nil] : nil; - NSString *davUrl = config[@"davUrl"]; NSString *accessToken = config[@"accessToken"]; - if (!davUrl || davUrl.length == 0 || !accessToken || accessToken.length == 0) { - completionHandler(nil, 0, NO, configUnavailableError(@"Server-Konfiguration oder Anmeldung fehlt")); + if (!accessToken || accessToken.length == 0) { + completionHandler(nil, 0, NO, configUnavailableError(@"Anmeldung fehlt")); return progress; } - // Resolve parent path from items plist + // Resolve parent path and davUrl from items plist. + // Each item carries its own davUrl so we use the correct space. NSString *parentPath = @""; - if (![parentId isEqualToString:NSFileProviderRootContainerItemIdentifier]) { - NSURL *metaURL = [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; - NSData *metaData = [NSData dataWithContentsOfURL:metaURL]; - if (metaData) { - NSArray *items = [NSPropertyListSerialization propertyListWithData:metaData - options:NSPropertyListImmutable format:nil error:nil]; - for (NSDictionary *item in items) { - if ([item[@"fileId"] isEqualToString:parentId]) { - parentPath = item[@"path"] ?: @""; - break; - } + NSString *davUrl = config[@"davUrl"]; // fallback to global config + NSURL *metaURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; + NSData *metaData = [NSData dataWithContentsOfURL:metaURL]; + if (metaData) { + NSArray *items = [NSPropertyListSerialization propertyListWithData:metaData + options:NSPropertyListImmutable format:nil error:nil]; + for (NSDictionary *item in items) { + if (![parentId isEqualToString:NSFileProviderRootContainerItemIdentifier] + && [item[@"fileId"] isEqualToString:parentId]) { + parentPath = item[@"path"] ?: @""; + if (item[@"davUrl"]) davUrl = item[@"davUrl"]; + break; + } + // For root-level items, grab davUrl from any item in the plist. + if ([parentId isEqualToString:NSFileProviderRootContainerItemIdentifier] + && item[@"davUrl"] && !davUrl) { + davUrl = item[@"davUrl"]; } } } + if (!davUrl || davUrl.length == 0) { + completionHandler(nil, 0, NO, configUnavailableError(@"Server-URL nicht konfiguriert")); + return progress; + } + NSString *filename = itemTemplate.filename; NSString *davBase = [davUrl hasSuffix:@"/"] ? [davUrl substringToIndex:davUrl.length - 1] : davUrl; @@ -605,16 +751,54 @@ - (NSProgress *)createItemBasedOnTemplate:(id)itemTemplate NSString *newFileId = [http.allHeaderFields[@"OC-FileId"] copy] ?: [NSString stringWithFormat:@"%@!%@", parentId, [[NSUUID UUID] UUIDString]]; - FileProviderItem *createdItem = [[FileProviderItem alloc] - initWithIdentifier:newFileId filename:filename parentIdentifier:parentId - isDirectory:YES size:0 modDate:[NSDate date]]; + NSString *dirPath = parentPath.length > 0 + ? [NSString stringWithFormat:@"%@/%@", parentPath, filename] + : filename; + + NSDictionary *dirDict = @{ + @"fileId": newFileId, + @"filename": filename, + @"path": dirPath, + @"parentId": parentId, + @"parentPath": parentPath, + @"isDirectory": @YES, + @"size": @0, + @"modtime": @((int64_t)[[NSDate date] timeIntervalSince1970]), + @"etag": @"", + @"isVirtualFile": @NO, + @"isDownloaded": @YES, + @"davUrl": davUrl, + }; + + FileProviderItem *createdItem = [[FileProviderItem alloc] initWithDictionary:dirDict]; os_log_info(extensionLog(), "createItem: directory created id=%{public}@", newFileId); + + [self _appendItemToPlist:dirDict]; + progress.completedUnitCount = 100; completionHandler(createdItem, NSFileProviderItemFields(0), NO, nil); }] resume]; } else { // --- File upload via PUT --- + + // Stage the content to a temporary file before the async upload. + // The system-provided content URL may become invalid after this + // method returns, so we must copy it synchronously. + NSString *stagingDir = NSTemporaryDirectory(); + NSString *stagingFilename = [NSString stringWithFormat:@"upload-%@-%@", + filename, [[NSUUID UUID] UUIDString]]; + NSURL *stagingURL = [NSURL fileURLWithPath:[stagingDir stringByAppendingPathComponent:stagingFilename]]; + + NSError *copyError = nil; + [[NSFileManager defaultManager] copyItemAtURL:url toURL:stagingURL error:©Error]; + if (copyError) { + os_log_error(extensionLog(), "createItem: failed to stage content: %{public}@", + copyError.localizedDescription); + completionHandler(nil, 0, NO, copyError); + return progress; + } + NSString *filePath = parentPath.length > 0 ? [NSString stringWithFormat:@"%@/%@", parentPath, filename] : filename; @@ -630,7 +814,17 @@ - (NSProgress *)createItemBasedOnTemplate:(id)itemTemplate [req setValue:[NSString stringWithFormat:@"Bearer %@", accessToken] forHTTPHeaderField:@"Authorization"]; NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]]; - [[session uploadTaskWithRequest:req fromFile:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + // Get file size before the async upload (staging file is deleted in the handler). + int64_t stagedFileSize = 0; + { + NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:stagingURL.path error:nil]; + if (attrs) stagedFileSize = [attrs[NSFileSize] longLongValue]; + } + + [[session uploadTaskWithRequest:req fromFile:stagingURL completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + // Clean up staging file. + [[NSFileManager defaultManager] removeItemAtURL:stagingURL error:nil]; + if (error) { os_log_error(extensionLog(), "createItem: PUT failed: %{public}@", error.localizedDescription); completionHandler(nil, 0, NO, error); @@ -648,13 +842,7 @@ - (NSProgress *)createItemBasedOnTemplate:(id)itemTemplate NSString *newFileId = [http.allHeaderFields[@"OC-FileId"] copy] ?: [NSString stringWithFormat:@"%@!%@", parentId, [[NSUUID UUID] UUIDString]]; - NSDictionary *sizeHeader = http.allHeaderFields; - int64_t fileSize = [sizeHeader[@"Content-Length"] longLongValue]; - if (fileSize == 0) { - // Get size from the uploaded file - NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:url.path error:nil]; - fileSize = [attrs[NSFileSize] longLongValue]; - } + int64_t fileSize = stagedFileSize; NSMutableDictionary *itemDict = [@{ @"fileId": newFileId, @@ -668,10 +856,18 @@ - (NSProgress *)createItemBasedOnTemplate:(id)itemTemplate @"etag": [http.allHeaderFields[@"ETag"] copy] ?: @"", @"isVirtualFile": @NO, @"isDownloaded": @YES, + @"davUrl": davUrl, } mutableCopy]; FileProviderItem *createdItem = [[FileProviderItem alloc] initWithDictionary:itemDict]; os_log_info(extensionLog(), "createItem: uploaded %{public}@ id=%{public}@", filename, newFileId); + + // Persist the new item in the shared plist so subsequent + // enumerations include it. Without this, the enumerator + // would not report the item, and fileproviderd would + // eventually remove it from Finder. + [self _appendItemToPlist:itemDict]; + progress.completedUnitCount = 100; completionHandler(createdItem, NSFileProviderItemFields(0), NO, nil); }] resume]; @@ -701,81 +897,142 @@ - (NSProgress *)modifyItem:(id)item NSString *fileId = [item.itemIdentifier copy]; - // Check which fields actually require XPC communication with the main app. - const NSFileProviderItemFields criticalFields = - NSFileProviderItemFilename | - NSFileProviderItemParentItemIdentifier | - NSFileProviderItemContents; + // Handle re-parent (move) via direct WebDAV MOVE — no XPC needed. + // This must be checked BEFORE the XPC proxy check below, otherwise + // moves fail when XPC is unavailable and Finder creates a copy instead. + if (changedFields & NSFileProviderItemParentItemIdentifier) { + NSString *newParentId = [item.parentItemIdentifier copy]; + os_log_info(extensionLog(), "modifyItem: moving %{public}@ to parent %{public}@", fileId, newParentId); - id proxy = _xpcService.remoteObjectProxy; - if (!proxy) { - if (changedFields & criticalFields) { - // Rename, move, or content upload requires XPC — report error. - os_log_error(extensionLog(), "modifyItem: no XPC proxy for critical change 0x%lx on %{public}@", - (unsigned long)changedFields, fileId); - completionHandler(nil, 0, NO, xpcUnavailableError()); + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) { + completionHandler(nil, 0, NO, configUnavailableError(@"App-Container nicht verfügbar")); return progress; } - // Non-critical fields (e.g. lastUsedDate, contentPolicy) — return item - // unchanged so fileproviderd does not mark it as errored. - os_log_info(extensionLog(), "modifyItem: no XPC needed for fields 0x%lx on %{public}@", - (unsigned long)changedFields, fileId); - FileProviderItem *unchanged = [[FileProviderItem alloc] - initWithIdentifier:fileId - filename:item.filename - parentIdentifier:item.parentItemIdentifier - isDirectory:NO - size:[item.documentSize longLongValue] - modDate:item.contentModificationDate]; - progress.completedUnitCount = 100; - completionHandler(unchanged, 0, NO, nil); - return progress; - } - // Handle rename. - if (changedFields & NSFileProviderItemFilename) { - NSString *newName = [item.filename copy]; - os_log_info(extensionLog(), "modifyItem: renaming %{public}@ to '%{public}@'", fileId, newName); + NSURL *cfgURL = [containerURL URLByAppendingPathComponent:configPlistName(self->_domain, containerURL)]; + NSData *cfgData = [NSData dataWithContentsOfURL:cfgURL]; + NSDictionary *cfg = cfgData ? [NSPropertyListSerialization propertyListWithData:cfgData + options:NSPropertyListImmutable format:nil error:nil] : nil; + NSString *movAccessToken = cfg[@"accessToken"]; + NSString *movDavUrl = cfg[@"davUrl"]; + + // Look up source path and per-item davUrl. + NSString *srcPath = nil; + NSString *newParentPath = @""; + NSURL *metURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; + NSData *metData = [NSData dataWithContentsOfURL:metURL]; + if (metData) { + NSArray *allItems = [NSPropertyListSerialization propertyListWithData:metData + options:NSPropertyListImmutable format:nil error:nil]; + for (NSDictionary *it in allItems) { + if ([it[@"fileId"] isEqualToString:fileId]) { + srcPath = it[@"path"]; + if (it[@"davUrl"]) movDavUrl = it[@"davUrl"]; + } + if ([it[@"fileId"] isEqualToString:newParentId]) { + newParentPath = it[@"path"] ?: @""; + } + } + } - [proxy renameItem:fileId newName:newName completionHandler:^(NSDictionary *itemDict, NSError *error) { + if (!srcPath || !movDavUrl || !movAccessToken) { + completionHandler(nil, 0, NO, configUnavailableError(@"Verschieben nicht möglich — Konfiguration fehlt")); + return progress; + } + + NSString *filename = item.filename; + NSString *destPath = newParentPath.length > 0 + ? [NSString stringWithFormat:@"%@/%@", newParentPath, filename] : filename; + NSString *movDavBase = [movDavUrl hasSuffix:@"/"] ? [movDavUrl substringToIndex:movDavUrl.length - 1] : movDavUrl; + + NSString *srcEncoded = [srcPath stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLPathAllowedCharacterSet]]; + NSString *destEncoded = [destPath stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLPathAllowedCharacterSet]]; + NSString *srcURLString = [NSString stringWithFormat:@"%@/%@", movDavBase, srcEncoded]; + NSString *destURLString = [NSString stringWithFormat:@"%@/%@", movDavBase, destEncoded]; + + os_log_info(extensionLog(), "modifyItem: MOVE %{public}@ -> %{public}@", srcURLString, destURLString); + + NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:srcURLString]]; + req.HTTPMethod = @"MOVE"; + [req setValue:[NSString stringWithFormat:@"Bearer %@", movAccessToken] forHTTPHeaderField:@"Authorization"]; + [req setValue:destURLString forHTTPHeaderField:@"Destination"]; + [req setValue:@"F" forHTTPHeaderField:@"Overwrite"]; + + NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]]; + [[session dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { if (error) { - os_log_error(extensionLog(), "modifyItem: rename failed: %{public}@", - error.localizedDescription); + os_log_error(extensionLog(), "modifyItem: MOVE failed: %{public}@", error.localizedDescription); completionHandler(nil, 0, NO, error); return; } + NSHTTPURLResponse *http = (NSHTTPURLResponse *)response; + if (http.statusCode < 200 || http.statusCode >= 300) { + os_log_error(extensionLog(), "modifyItem: MOVE HTTP %ld", (long)http.statusCode); + completionHandler(nil, 0, NO, [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Verschieben fehlgeschlagen (HTTP %ld)", (long)http.statusCode]}]); + return; + } - FileProviderItem *updatedItem = [[FileProviderItem alloc] initWithDictionary:itemDict]; - os_log_info(extensionLog(), "modifyItem: rename succeeded for %{public}@", fileId); + // Update the plist entry in-place: change path/parent but do NOT + // mark as extensionCreated. The file was already in the journal — + // the sync engine will discover it at the new location. Without + // extensionCreated, server-side deletions propagate correctly. + [self _updateItemPathInPlist:fileId + newPath:destPath + newParentId:newParentId + newParentPath:newParentPath + davUrl:movDavUrl]; + + FileProviderItem *movedItem = [[FileProviderItem alloc] + initWithIdentifier:fileId filename:filename parentIdentifier:newParentId + isDirectory:NO size:[item.documentSize longLongValue] modDate:item.contentModificationDate]; + os_log_info(extensionLog(), "modifyItem: MOVE succeeded for %{public}@", fileId); progress.completedUnitCount = 100; - completionHandler(updatedItem, NSFileProviderItemFields(0), NO, nil); - }]; + completionHandler(movedItem, NSFileProviderItemFields(0), NO, nil); + }] resume]; return progress; } - // Handle re-parent (move). - if (changedFields & NSFileProviderItemParentItemIdentifier) { - NSString *newParentId = [item.parentItemIdentifier copy]; - os_log_info(extensionLog(), "modifyItem: moving %{public}@ to parent %{public}@", fileId, newParentId); + // For remaining operations (rename, content update), check XPC availability. + id proxy = _xpcService.remoteObjectProxy; + + // Handle rename via XPC. + if (changedFields & NSFileProviderItemFilename) { + if (!proxy) { + os_log_error(extensionLog(), "modifyItem: no XPC proxy for rename of %{public}@", fileId); + completionHandler(nil, 0, NO, xpcUnavailableError()); + return progress; + } + NSString *newName = [item.filename copy]; + os_log_info(extensionLog(), "modifyItem: renaming %{public}@ to '%{public}@'", fileId, newName); - [proxy moveItem:fileId newParent:newParentId completionHandler:^(NSDictionary *itemDict, NSError *error) { + [proxy renameItem:fileId newName:newName completionHandler:^(NSDictionary *itemDict, NSError *error) { if (error) { - os_log_error(extensionLog(), "modifyItem: move failed: %{public}@", + os_log_error(extensionLog(), "modifyItem: rename failed: %{public}@", error.localizedDescription); completionHandler(nil, 0, NO, error); return; } FileProviderItem *updatedItem = [[FileProviderItem alloc] initWithDictionary:itemDict]; - os_log_info(extensionLog(), "modifyItem: move succeeded for %{public}@", fileId); + os_log_info(extensionLog(), "modifyItem: rename succeeded for %{public}@", fileId); progress.completedUnitCount = 100; completionHandler(updatedItem, NSFileProviderItemFields(0), NO, nil); }]; return progress; } - // Handle content update (re-upload). + // Handle content update (re-upload via XPC). if (changedFields & NSFileProviderItemContents) { + if (!proxy) { + os_log_error(extensionLog(), "modifyItem: no XPC proxy for content update of %{public}@", fileId); + completionHandler(nil, 0, NO, xpcUnavailableError()); + return progress; + } if (!newContents) { os_log_error(extensionLog(), "modifyItem: content change flagged but no content URL for %{public}@", fileId); @@ -852,28 +1109,96 @@ - (NSProgress *)deleteItemWithIdentifier:(NSFileProviderItemIdentifier)identifie os_log_info(extensionLog(), "deleteItem: %{public}@", identifier); NSProgress *progress = [NSProgress discreteProgressWithTotalUnitCount:1]; + NSString *fileId = [identifier copy]; - id proxy = _xpcService.remoteObjectProxy; - if (!proxy) { - os_log_error(extensionLog(), "deleteItem: no XPC proxy available"); - completionHandler(xpcUnavailableError()); + // --- Direct WebDAV DELETE (no XPC needed) --- + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) { + completionHandler(configUnavailableError(@"App-Container nicht verfügbar")); return progress; } - NSString *fileId = [identifier copy]; + // Read access token from global config. + NSURL *configURL = [containerURL URLByAppendingPathComponent:configPlistName(self->_domain, containerURL)]; + NSData *configData = [NSData dataWithContentsOfURL:configURL]; + NSDictionary *config = configData + ? [NSPropertyListSerialization propertyListWithData:configData options:NSPropertyListImmutable format:nil error:nil] + : nil; + NSString *davUrl = config[@"davUrl"]; // fallback + NSString *accessToken = config[@"accessToken"]; + + if (!accessToken || accessToken.length == 0) { + completionHandler(configUnavailableError(@"Anmeldung fehlt")); + return progress; + } + + // Look up file path and per-item davUrl from items plist. + NSString *filePath = nil; + NSURL *metaURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; + NSData *metaData = [NSData dataWithContentsOfURL:metaURL]; + if (metaData) { + NSArray *items = [NSPropertyListSerialization propertyListWithData:metaData + options:NSPropertyListImmutable format:nil error:nil]; + for (NSDictionary *item in items) { + if ([item[@"fileId"] isEqualToString:fileId]) { + filePath = item[@"path"]; + if (item[@"davUrl"]) davUrl = item[@"davUrl"]; + break; + } + } + } + + if (!davUrl || davUrl.length == 0) { + completionHandler(configUnavailableError(@"Server-URL nicht konfiguriert")); + return progress; + } - [proxy deleteItem:fileId completionHandler:^(NSError *error) { + if (!filePath || filePath.length == 0) { + os_log_error(extensionLog(), "deleteItem: fileId %{public}@ not found in items plist", fileId); + completionHandler([NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorNoSuchItem + userInfo:@{NSLocalizedDescriptionKey: @"Item not found in metadata"}]); + return progress; + } + + NSString *davBase = [davUrl hasSuffix:@"/"] ? [davUrl substringToIndex:davUrl.length - 1] : davUrl; + NSString *encodedPath = [filePath stringByAddingPercentEncodingWithAllowedCharacters: + [NSCharacterSet URLPathAllowedCharacterSet]]; + NSString *deleteURLString = [NSString stringWithFormat:@"%@/%@", davBase, encodedPath]; + NSURL *deleteURL = [NSURL URLWithString:deleteURLString]; + + os_log_info(extensionLog(), "deleteItem: DELETE %{public}@", deleteURLString); + + NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:deleteURL]; + req.HTTPMethod = @"DELETE"; + [req setValue:[NSString stringWithFormat:@"Bearer %@", accessToken] forHTTPHeaderField:@"Authorization"]; + + NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]]; + [[session dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { if (error) { - os_log_error(extensionLog(), "deleteItem: failed for %{public}@: %{public}@", - fileId, error.localizedDescription); + os_log_error(extensionLog(), "deleteItem: DELETE failed: %{public}@", error.localizedDescription); completionHandler(error); return; } - - os_log_info(extensionLog(), "deleteItem: succeeded for %{public}@", fileId); - progress.completedUnitCount = 1; - completionHandler(nil); - }]; + NSHTTPURLResponse *http = (NSHTTPURLResponse *)response; + if (http.statusCode >= 200 && http.statusCode < 300) { + os_log_info(extensionLog(), "deleteItem: DELETE succeeded for %{public}@ (HTTP %ld)", + fileId, (long)http.statusCode); + // Remove the item from the shared plist so the enumerator + // no longer returns it. + [self _removeStaleItemFromPlist:fileId]; + progress.completedUnitCount = 1; + completionHandler(nil); + } else { + os_log_error(extensionLog(), "deleteItem: DELETE HTTP %ld for %{public}@", + (long)http.statusCode, fileId); + completionHandler([NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Löschen fehlgeschlagen (HTTP %ld)", (long)http.statusCode]}]); + } + }] resume]; return progress; } @@ -893,7 +1218,8 @@ - (NSProgress *)deleteItemWithIdentifier:(NSFileProviderItemIdentifier)identifie FileProviderEnumerator *enumerator = [[FileProviderEnumerator alloc] initWithContainerIdentifier:containerItemIdentifier - xpcService:_xpcService]; + xpcService:_xpcService + domain:_domain]; return enumerator; } diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index a6b0417b02..b8e08f851d 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -207,10 +207,17 @@ else() PROPERTIES MACOSX_PACKAGE_LOCATION Resources ) + # Generate main app entitlements with the configured App Group identifier. + set(APP_GROUP_IDENTIFIER "${APPLE_DEVELOPMENT_TEAM}.${APPLICATION_REV_DOMAIN}") + configure_file( + "${CMAKE_SOURCE_DIR}/src/OpenCloud.entitlements" + "${CMAKE_CURRENT_BINARY_DIR}/OpenCloud.entitlements" + @ONLY + ) set_target_properties(opencloud PROPERTIES OUTPUT_NAME "${APPLICATION_SHORTNAME}" MACOSX_BUNDLE_INFO_PLIST ${CMAKE_CURRENT_SOURCE_DIR}/MacOSXBundleInfo.plist - XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_SOURCE_DIR}/src/OpenCloud.entitlements" + XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_CURRENT_BINARY_DIR}/OpenCloud.entitlements" XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "$(CODE_SIGN_IDENTITY)" XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "${APPLE_DEVELOPMENT_TEAM}" XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME NO diff --git a/src/plugins/vfs/nsfp/nsfpdomainmanager.h b/src/plugins/vfs/nsfp/nsfpdomainmanager.h index b96924ec2e..a1a5b1bef9 100644 --- a/src/plugins/vfs/nsfp/nsfpdomainmanager.h +++ b/src/plugins/vfs/nsfp/nsfpdomainmanager.h @@ -64,6 +64,10 @@ class NsfpDomainManager /// @param completionHandler Called with empty string on success, error description on failure. void evictItem(const QString &identifier, const QString &fileId, NsfpDomainCompletionHandler completionHandler); + /// Signal the working set enumerator. This covers ALL items across all + /// folders and is critical for detecting deletions in subdirectories. + void signalWorkingSet(const QString &identifier); + /// Signal the system to perform storage-pressure eviction. /// The framework will decide which items to evict based on their /// allowsEviction capability and last-access timestamps. diff --git a/src/plugins/vfs/nsfp/nsfpdomainmanager.mm b/src/plugins/vfs/nsfp/nsfpdomainmanager.mm index 84f0239669..39ca5984f3 100644 --- a/src/plugins/vfs/nsfp/nsfpdomainmanager.mm +++ b/src/plugins/vfs/nsfp/nsfpdomainmanager.mm @@ -351,6 +351,35 @@ }); } +void NsfpDomainManager::signalWorkingSet(const QString &identifier) +{ + qCInfo(lcNsfpDomainManager) << "signalWorkingSet requested for domain:" << identifier; + + QString identifierCopy = identifier; + dispatch_async(_p->dispatchQueue, ^{ + NSFileProviderManager *manager = nil; + { + QMutexLocker lock(&_p->cacheMutex); + manager = _p->managerCache.value(identifierCopy, nil); + } + + if (!manager) { + qCWarning(lcNsfpDomainManager) << "signalWorkingSet: no manager for domain:" << identifierCopy; + return; + } + + [manager signalEnumeratorForContainerItemIdentifier:NSFileProviderWorkingSetContainerItemIdentifier + completionHandler:^(NSError *error) { + if (error) { + qCWarning(lcNsfpDomainManager) << "signalWorkingSet failed:" + << QString::fromNSString(error.localizedDescription); + } else { + qCDebug(lcNsfpDomainManager) << "signalWorkingSet succeeded for domain:" << identifierCopy; + } + }]; + }); +} + void NsfpDomainManager::evictItem(const QString &identifier, const QString &fileId, NsfpDomainCompletionHandler completionHandler) { diff --git a/src/plugins/vfs/nsfp/vfs_nsfp.mm b/src/plugins/vfs/nsfp/vfs_nsfp.mm index 18828fba64..48a6696b91 100644 --- a/src/plugins/vfs/nsfp/vfs_nsfp.mm +++ b/src/plugins/vfs/nsfp/vfs_nsfp.mm @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -41,7 +42,7 @@ /// Writes the WebDAV URL and access token to the App Group shared container /// so the FileProvider extension can download file contents directly from the server. -static void syncConfigToSharedContainer(const VfsSetupParams ¶ms) +static void syncConfigToSharedContainer(const VfsSetupParams ¶ms, const QString &domainId) { NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; @@ -50,17 +51,18 @@ static void syncConfigToSharedContainer(const VfsSetupParams ¶ms) return; } + // Per-domain config file so multiple accounts/spaces don't overwrite each other. + NSString *configFilename = [NSString stringWithFormat:@"fileprovider_config_%@.plist", + domainId.toNSString()]; + // Extract access token from credentials. QString accessToken; if (auto *httpCreds = qobject_cast(params.account->credentials())) { accessToken = httpCreds->accessToken(); } - // Don't overwrite an existing config with an empty token — the extension - // would lose the ability to download files until the token is refreshed. if (accessToken.isEmpty()) { - // Check if a config with a valid token already exists. - NSURL *existingConfig = [containerURL URLByAppendingPathComponent:@"fileprovider_config.plist"]; + NSURL *existingConfig = [containerURL URLByAppendingPathComponent:configFilename]; NSData *existingData = [NSData dataWithContentsOfURL:existingConfig]; if (existingData) { NSDictionary *existing = [NSPropertyListSerialization propertyListWithData:existingData @@ -75,10 +77,6 @@ static void syncConfigToSharedContainer(const VfsSetupParams ¶ms) qCWarning(lcVfsNSFP) << "syncConfigToSharedContainer: writing config with empty token (credentials not yet available)"; } - // OCIS space IDs use '$' as a separator (e.g. driveId$dirId). When constructing a - // WebDAV URL the server expects this character to be percent-encoded as '%24'. - // QUrl considers '$' a valid path character (sub-delimiter per RFC 3986) and never - // encodes it, even with QUrl::FullyEncoded. We must replace it manually. auto davUrl = params.baseUrl().toString(QUrl::FullyEncoded); davUrl.replace(QLatin1Char('$'), QStringLiteral("%24")); @@ -87,7 +85,7 @@ static void syncConfigToSharedContainer(const VfsSetupParams ¶ms) @"accessToken": accessToken.toNSString(), }; - NSURL *configURL = [containerURL URLByAppendingPathComponent:@"fileprovider_config.plist"]; + NSURL *configURL = [containerURL URLByAppendingPathComponent:configFilename]; NSError *error = nil; NSData *data = [NSPropertyListSerialization dataWithPropertyList:config format:NSPropertyListBinaryFormat_v1_0 @@ -98,19 +96,40 @@ static void syncConfigToSharedContainer(const VfsSetupParams ¶ms) return; } - // Make file readable by the extension (group container is accessible to all group members). [data writeToURL:configURL atomically:YES]; - // Set read permissions for group members [[NSFileManager defaultManager] setAttributes:@{NSFilePosixPermissions: @0644} ofItemAtPath:configURL.path error:nil]; - qCInfo(lcVfsNSFP) << "syncConfigToSharedContainer: wrote config with davUrl" << davUrl; + qCInfo(lcVfsNSFP) << "syncConfigToSharedContainer: wrote config for domain" << domainId << "davUrl" << davUrl; + + // One-time cleanup: remove legacy global files (and their stale caches) + // only if they still exist. Once removed, this block is a no-op. + { + NSFileManager *fm = [NSFileManager defaultManager]; + NSURL *legacyConfig = [containerURL URLByAppendingPathComponent:@"fileprovider_config.plist"]; + NSURL *legacyItems = [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; + BOOL hasLegacy = [fm fileExistsAtPath:legacyConfig.path] + || [fm fileExistsAtPath:legacyItems.path]; + if (hasLegacy) { + [fm removeItemAtURL:legacyConfig error:nil]; + [fm removeItemAtURL:legacyItems error:nil]; + qCInfo(lcVfsNSFP) << "Removed legacy plist files"; + // Also remove stale prevFileIds caches from the legacy era. + NSArray *contents = [fm contentsOfDirectoryAtPath:containerURL.path error:nil]; + for (NSString *name in contents) { + if ([name hasPrefix:@"prevFileIds_"]) { + [fm removeItemAtURL:[containerURL URLByAppendingPathComponent:name] error:nil]; + qCInfo(lcVfsNSFP) << "Removed stale cache:" << QString::fromNSString(name); + } + } + } + } } /// Writes all file records from the sync journal to a plist file in the /// App Group shared container so the FileProvider extension can enumerate items /// without needing an XPC connection to the main app. -static void syncMetadataToSharedContainer(SyncJournalDb *journal) +static void syncMetadataToSharedContainer(SyncJournalDb *journal, const VfsSetupParams ¶ms, const QString &domainId) { if (!journal) { qCWarning(lcVfsNSFP) << "syncMetadataToSharedContainer: no journal"; @@ -179,7 +198,7 @@ static void syncMetadataToSharedContainer(SyncJournalDb *journal) records.append(info); }); - os_log_fault(OS_LOG_DEFAULT, "syncMetadataToSharedContainer: callbacks=%d invalid=%d dirs=%d virtualFiles=%d other=%d records=%d", + os_log_info(OS_LOG_DEFAULT, "syncMetadataToSharedContainer: callbacks=%d invalid=%d dirs=%d virtualFiles=%d other=%d records=%d", totalCallbacks, invalidCount, dirCount, virtualFileCount, otherCount, (int)records.size()); // If the journal query returned no virtual files, they may have been deleted @@ -245,6 +264,13 @@ static void syncMetadataToSharedContainer(SyncJournalDb *journal) } } + // Compute the davUrl for this space so each item carries its own WebDAV + // base URL. This prevents cross-space confusion when multiple spaces are + // synced simultaneously (each space has a different davUrl). + auto davUrl = params.baseUrl().toString(QUrl::FullyEncoded); + davUrl.replace(QLatin1Char('$'), QStringLiteral("%24")); + NSString *nsDavUrl = davUrl.toNSString(); + // Second pass: resolve parent file IDs and build the plist array. NSMutableArray *items = [NSMutableArray arrayWithCapacity:records.size()]; for (const auto &info : records) { @@ -268,14 +294,77 @@ static void syncMetadataToSharedContainer(SyncJournalDb *journal) @"modtime" : @(info.modtime), @"etag" : info.etag.toNSString() ?: @"", @"isVirtualFile" : @(info.isVirtualFile), - // isDownloaded: true for fully hydrated files, false for virtual/dehydrated placeholders. - // Directories are always considered "downloaded" since they have no content to fetch. @"isDownloaded" : @(info.isDirectory || !info.isVirtualFile), + @"davUrl" : nsDavUrl, }; [items addObject:dict]; } - NSURL *metadataURL = [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; + // Preserve items that were added by the FileProvider extension (e.g. via + // createItemBasedOnTemplate) but haven't been synced to the journal yet. + // Without this merge, syncMetadataToSharedContainer would overwrite the + // plist and the extension-created items would be reported as deleted by + // the enumerator's change-detection diff. + NSString *itemsFilename = [NSString stringWithFormat:@"fileprovider_items_%@.plist", + domainId.toNSString()]; + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:itemsFilename]; + { + NSData *existingData = [NSData dataWithContentsOfURL:metadataURL]; + if (existingData) { + NSArray *existingItems = [NSPropertyListSerialization propertyListWithData:existingData + options:NSPropertyListImmutable + format:nil error:nil]; + if ([existingItems isKindOfClass:[NSArray class]]) { + // Build a set of fileIds from the journal so we can quickly + // check whether an existing plist entry is already covered. + NSMutableSet *journalFileIds = [NSMutableSet setWithCapacity:items.count]; + // Also track paths to detect items at the same location. + NSMutableSet *journalPaths = [NSMutableSet setWithCapacity:items.count]; + for (NSDictionary *item in items) { + [journalFileIds addObject:item[@"fileId"] ?: @""]; + [journalPaths addObject:item[@"path"] ?: @""]; + } + + // Only preserve extension-created items that are recent (< 120s). + // After that the sync engine should have discovered them. If they + // are still not in the journal, they were deleted on the server. + int64_t now = (int64_t)[[NSDate date] timeIntervalSince1970]; + static const int64_t MAX_PRESERVE_AGE = 120; // seconds + + int preservedCount = 0; + for (NSDictionary *existing in existingItems) { + BOOL isExtCreated = [existing[@"extensionCreated"] boolValue]; + int64_t movedAt = [existing[@"movedAt"] longLongValue]; + + if (!isExtCreated && movedAt == 0) { + // Regular journal item — not preserved. + continue; + } + + // Check TTL for both extension-created and moved items. + int64_t timestamp = isExtCreated + ? [existing[@"extensionCreatedAt"] longLongValue] + : movedAt; + if (timestamp > 0 && (now - timestamp) > MAX_PRESERVE_AGE) { + continue; + } + + NSString *existingId = existing[@"fileId"] ?: @""; + NSString *existingPath = existing[@"path"] ?: @""; + if (![journalFileIds containsObject:existingId] + && ![journalPaths containsObject:existingPath]) { + [items addObject:existing]; + preservedCount++; + } + } + if (preservedCount > 0) { + qCInfo(lcVfsNSFP) << "syncMetadataToSharedContainer: preserved" + << preservedCount << "extension-created items not yet in journal"; + } + } + } + } + NSError *writeError = nil; NSData *data = [NSPropertyListSerialization dataWithPropertyList:items format:NSPropertyListBinaryFormat_v1_0 @@ -291,6 +380,40 @@ static void syncMetadataToSharedContainer(SyncJournalDb *journal) } } +/// Reads the metadata plist for the given domain and returns the set of unique +/// parent container identifiers (folder fileIds). Used to determine which folder +/// enumerators need signalling after a sync cycle so that deletions in +/// subdirectories are detected. +static QSet collectParentContainerIds(const QString &domainId) +{ + QSet result; + + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) return result; + + NSString *filename = [NSString stringWithFormat:@"fileprovider_items_%@.plist", + domainId.toNSString()]; + NSURL *url = [containerURL URLByAppendingPathComponent:filename]; + NSData *data = [NSData dataWithContentsOfURL:url]; + if (!data) return result; + + NSArray *items = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListImmutable + format:nil error:nil]; + if (![items isKindOfClass:[NSArray class]]) return result; + + for (NSDictionary *item in items) { + NSString *parentId = item[@"parentId"]; + if (parentId.length > 0 + && ![parentId isEqualToString:NSFileProviderRootContainerItemIdentifier]) { + result.insert(QString::fromNSString(parentId)); + } + } + + return result; +} + VfsNSFP::VfsNSFP(QObject *parent) : Vfs(parent) { @@ -413,7 +536,7 @@ static void syncMetadataToSharedContainer(SyncJournalDb *journal) // If parentContainerId is empty, signalEnumerator will use root container. // Update the shared metadata file so the extension can see the new item. - syncMetadataToSharedContainer(journal); + syncMetadataToSharedContainer(journal, params(), _domainId); // Signal the File Provider framework to re-enumerate the parent container // so Finder picks up the new placeholder. @@ -778,7 +901,7 @@ static void syncMetadataToSharedContainer(SyncJournalDb *journal) } // Update the shared metadata so the extension sees the changes. - syncMetadataToSharedContainer(journal); + syncMetadataToSharedContainer(journal, params(), _domainId); // Signal the File Provider framework to refresh Finder's view. _domainManager->signalEnumerator(_domainId, parentContainerId); @@ -859,7 +982,7 @@ static void syncMetadataToSharedContainer(SyncJournalDb *journal) this, [self]() { if (self) { qCInfo(lcVfsNSFP) << "Credentials fetched — updating extension config"; - syncConfigToSharedContainer(self->params()); + syncConfigToSharedContainer(self->params(), self->_domainId); } }); @@ -879,12 +1002,12 @@ static void syncMetadataToSharedContainer(SyncJournalDb *journal) // Write initial file metadata to the shared container // so the extension can enumerate items immediately. - syncMetadataToSharedContainer(self->params().journal); + syncMetadataToSharedContainer(self->params().journal, self->params(), self->_domainId); // Write WebDAV URL + access token so extension can download directly. // The fetched() signal connection was already established before // addDomain to avoid race conditions. - syncConfigToSharedContainer(self->params()); + syncConfigToSharedContainer(self->params(), self->_domainId); // Signal the enumerator so fileproviderd picks up the new items. self->_domainManager->signalEnumerator(self->_domainId, QString()); @@ -899,10 +1022,41 @@ static void syncMetadataToSharedContainer(SyncJournalDb *journal) return; } qCInfo(lcVfsNSFP) << "Sync finished (success=" << success << ") — refreshing shared metadata"; - syncMetadataToSharedContainer(self->params().journal); - syncConfigToSharedContainer(self->params()); - // Signal both root and working set so Finder updates all views. + + // Collect parent container IDs BEFORE updating the plist. + // This captures containers that currently have items — if items + // are removed (remote delete), the container's enumerator must + // be signalled so it can detect the deletion via its prevFileIds diff. + const auto oldParentIds = collectParentContainerIds(self->_domainId); + + syncMetadataToSharedContainer(self->params().journal, self->params(), self->_domainId); + syncConfigToSharedContainer(self->params(), self->_domainId); + + // Collect parent container IDs AFTER updating the plist. + const auto newParentIds = collectParentContainerIds(self->_domainId); + + // Signal all affected parent containers (union of old and new). + // Old containers need signalling to detect item deletions/moves-out. + // New containers need signalling to detect item additions/moves-in. + auto allParentIds = oldParentIds; + allParentIds.unite(newParentIds); + + if (!allParentIds.isEmpty()) { + qCInfo(lcVfsNSFP) << "Signalling" << allParentIds.size() + << "parent container enumerators after sync"; + } + for (const auto &parentId : allParentIds) { + self->_domainManager->signalEnumerator(self->_domainId, parentId); + } + + // Signal root container. self->_domainManager->signalEnumerator(self->_domainId, QString()); + + // Signal working set — this enumerator covers ALL items across + // all folders and is the most reliable way to detect deletions + // in subdirectories (its prevFileIds cache spans everything). + self->_domainManager->signalWorkingSet(self->_domainId); + self->_domainManager->requestSystemEviction(self->_domainId); }); } @@ -917,7 +1071,7 @@ static void syncMetadataToSharedContainer(SyncJournalDb *journal) return; } // Keep the access token up to date for the extension. - syncConfigToSharedContainer(self->params()); + syncConfigToSharedContainer(self->params(), self->_domainId); // Ask the sync scheduler to run a sync cycle. Q_EMIT self->needSync(); }); From 62db1b815faedeaeb803a585d5b5641f96ba1c4c Mon Sep 17 00:00:00 2001 From: Justin Mester Date: Thu, 26 Mar 2026 10:43:02 +0100 Subject: [PATCH 4/5] Add missing nsfpdiagnostic source files These files are referenced in src/cmd/CMakeLists.txt but were not tracked in git, causing build failures on other machines. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cmd/nsfpdiagnostic.h | 17 +++++++ src/cmd/nsfpdiagnostic.mm | 95 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/cmd/nsfpdiagnostic.h create mode 100644 src/cmd/nsfpdiagnostic.mm diff --git a/src/cmd/nsfpdiagnostic.h b/src/cmd/nsfpdiagnostic.h new file mode 100644 index 0000000000..aa0b766381 --- /dev/null +++ b/src/cmd/nsfpdiagnostic.h @@ -0,0 +1,17 @@ +// nsfpdiagnostic -- Diagnostic utility for dumping NSFileProvider domain information. +// Invoked via the --dump-nsfp-domains CLI flag on macOS. +// 2026-03-07: Initial creation (VOD-027). +#pragma once + +#ifdef Q_OS_MACOS + +namespace OCC { + +/// Queries all registered NSFileProvider domains and prints their details +/// (identifier, display name, path) to stdout. Blocks the calling thread +/// until the query completes via a semaphore. Returns 0 on success, 1 on error. +int dumpNSFileProviderDomains(); + +} // namespace OCC + +#endif // Q_OS_MACOS diff --git a/src/cmd/nsfpdiagnostic.mm b/src/cmd/nsfpdiagnostic.mm new file mode 100644 index 0000000000..8361f62a55 --- /dev/null +++ b/src/cmd/nsfpdiagnostic.mm @@ -0,0 +1,95 @@ +// nsfpdiagnostic -- Dumps registered NSFileProvider domains to stdout. +// Used for developer diagnostics via the --dump-nsfp-domains CLI flag. +// 2026-03-07: Initial creation (VOD-027). + +#import +#import + +#include +#include + +namespace OCC { + +int dumpNSFileProviderDomains() +{ + if (@available(macOS 11.0, *)) { + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + __block int result = 0; + + [NSFileProviderManager getDomainsWithCompletionHandler:^(NSArray *domains, + NSError *error) { + if (error) { + std::cerr << "Error querying NSFileProvider domains: " + << error.localizedDescription.UTF8String << std::endl; + result = 1; + dispatch_semaphore_signal(semaphore); + return; + } + + if (domains.count == 0) { + std::cout << "No NSFileProvider domains registered." << std::endl; + dispatch_semaphore_signal(semaphore); + return; + } + + std::cout << "NSFileProvider Domains:" << std::endl; + + for (NSFileProviderDomain *domain in domains) { + std::string separator(41, '-'); + std::cout << separator << std::endl; + + std::cout << "Identifier: " + << (domain.identifier ? domain.identifier.UTF8String : "(null)") + << std::endl; + + std::cout << "Display Name: " + << (domain.displayName ? domain.displayName.UTF8String : "(null)") + << std::endl; + + // Retrieve the user-visible URL for this domain's root. + NSFileProviderManager *manager = + [NSFileProviderManager managerForDomain:domain]; + if (manager) { + dispatch_semaphore_t urlSemaphore = dispatch_semaphore_create(0); + __block NSString *pathString = nil; + + [manager getUserVisibleURLForItemIdentifier:NSFileProviderRootContainerItemIdentifier + completionHandler:^(NSURL *url, NSError *urlError) { + if (url) { + pathString = [url.path copy]; + } else if (urlError) { + pathString = [NSString stringWithFormat:@"(error: %@)", + urlError.localizedDescription]; + } + dispatch_semaphore_signal(urlSemaphore); + }]; + + dispatch_semaphore_wait(urlSemaphore, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(5 * NSEC_PER_SEC))); + + std::cout << "Path: " + << (pathString ? pathString.UTF8String : "(unavailable)") + << std::endl; + } else { + std::cout << "Path: (no manager available)" << std::endl; + } + } + + std::string separator(41, '-'); + std::cout << separator << std::endl; + std::cout << "Total: " << domains.count << " domain(s)" << std::endl; + + dispatch_semaphore_signal(semaphore); + }]; + + // Wait up to 30 seconds for the async query to complete. + dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(30 * NSEC_PER_SEC))); + return result; + } else { + std::cerr << "NSFileProvider domains require macOS 11.0 or later." << std::endl; + return 1; + } +} + +} // namespace OCC From 64e170564dc777e0b0128c7d2f4d629a89aedc40 Mon Sep 17 00:00:00 2001 From: Justin Mester Date: Thu, 26 Mar 2026 11:05:37 +0100 Subject: [PATCH 5/5] Add macOS xattr-based VFS plugin (fileprovider) Alternative VFS plugin using extended attributes for placeholder tracking. Not enabled by default (not in VIRTUAL_FILE_SYSTEM_PLUGINS) but available for builds that prefer xattr-based dehydration over the NSFileProvider approach. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/plugins/vfs/fileprovider/CMakeLists.txt | 7 + .../vfs/fileprovider/vfs_fileprovider.cpp | 285 ++++++++++++++++++ .../vfs/fileprovider/vfs_fileprovider.h | 65 ++++ 3 files changed, 357 insertions(+) create mode 100644 src/plugins/vfs/fileprovider/CMakeLists.txt create mode 100644 src/plugins/vfs/fileprovider/vfs_fileprovider.cpp create mode 100644 src/plugins/vfs/fileprovider/vfs_fileprovider.h diff --git a/src/plugins/vfs/fileprovider/CMakeLists.txt b/src/plugins/vfs/fileprovider/CMakeLists.txt new file mode 100644 index 0000000000..96df928486 --- /dev/null +++ b/src/plugins/vfs/fileprovider/CMakeLists.txt @@ -0,0 +1,7 @@ +if(APPLE) + add_vfs_plugin(NAME fileprovider + SRC + vfs_fileprovider.cpp + LIBS + ) +endif() diff --git a/src/plugins/vfs/fileprovider/vfs_fileprovider.cpp b/src/plugins/vfs/fileprovider/vfs_fileprovider.cpp new file mode 100644 index 0000000000..24d2db61cb --- /dev/null +++ b/src/plugins/vfs/fileprovider/vfs_fileprovider.cpp @@ -0,0 +1,285 @@ +/* + * SPDX-FileCopyrightText: 2025 OpenCloud GmbH and OpenCloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + * + * VFS plugin for macOS: Files on Demand via placeholder files and xattr + * (no OpenVFS). Placeholders are empty files with xattr: fileId, size, modtime, pinstate. + */ + +#include "vfs_fileprovider.h" + +#include "common/syncjournaldb.h" +#include "common/syncjournalfilerecord.h" +#include "filesystem.h" +#include "libsync/syncfileitem.h" +#include "libsync/theme.h" +#include "libsync/xattr.h" + +#include +#include +#include + +using namespace OCC; +using namespace Qt::StringLiterals; + +Q_LOGGING_CATEGORY(lcVfsFileProvider, "sync.vfs.fileprovider", QtInfoMsg) + +namespace { + +const QString XATTR_FILE_ID = QStringLiteral("eu.opencloud.desktop.vfs.fileid"); +const QString XATTR_SIZE = QStringLiteral("eu.opencloud.desktop.vfs.size"); +const QString XATTR_MODTIME = QStringLiteral("eu.opencloud.desktop.vfs.modtime"); +const QString XATTR_PLACEHOLDER = QStringLiteral("eu.opencloud.desktop.vfs.placeholder"); +const QString XATTR_PIN_STATE = QStringLiteral("eu.opencloud.desktop.vfs.pinstate"); + +bool hasPlaceholderXattr(const std::filesystem::path &path) +{ + const auto data = FileSystem::Xattr::getxattr(path, XATTR_PLACEHOLDER); + return data && *data == QByteArrayLiteral("1"); +} + +std::optional getPlaceholderFileId(const std::filesystem::path &path) +{ + return FileSystem::Xattr::getxattr(path, XATTR_FILE_ID); +} + +bool setPinStateXattr(const std::filesystem::path &path, PinState state) +{ + const auto result = FileSystem::Xattr::setxattr(path, XATTR_PIN_STATE, QByteArray::number(static_cast(state))); + return static_cast(result); +} + +Optional getPinStateXattr(const std::filesystem::path &path) +{ + const auto data = FileSystem::Xattr::getxattr(path, XATTR_PIN_STATE); + if (!data || data->isEmpty()) + return {}; + bool ok = false; + const int v = data->toInt(&ok); + if (!ok || v < 0 || v > 4) + return {}; + return static_cast(v); +} + +} // namespace + +VfsMacFileProvider::VfsMacFileProvider(QObject *parent) + : Vfs(parent) +{ +} + +VfsMacFileProvider::~VfsMacFileProvider() = default; + +Vfs::Mode VfsMacFileProvider::mode() const +{ + return Vfs::Mode::MacFileProvider; +} + +void VfsMacFileProvider::stop() +{ + for (auto *job : _hydrationJobs) + job->abort(); + _hydrationJobs.clear(); +} + +void VfsMacFileProvider::unregisterFolder() { } + +bool VfsMacFileProvider::socketApiPinStateActionsShown() const +{ + return true; +} + +Result VfsMacFileProvider::createPlaceholder(const SyncFileItem &item) +{ + const auto path = params().root() / item.localName(); + if (path.exists() && !item.isDirectory()) { + if (item._type == ItemTypeVirtualFileDehydration && FileSystem::fileChanged(path.get(), FileSystem::FileChangedInfo::fromSyncFileItem(&item))) { + return tr("The file has changed since discovery"); + } + } + QFile file(path.toString()); + if (!file.open(QFile::ReadWrite | QFile::Truncate)) { + return file.errorString(); + } + file.write(""); + file.close(); + + const auto fsPath = path.get(); + if (const auto r = FileSystem::Xattr::setxattr(fsPath, XATTR_PLACEHOLDER, QByteArrayLiteral("1")); !r) + return r.error(); + if (const auto r = FileSystem::Xattr::setxattr(fsPath, XATTR_FILE_ID, item._fileId); !r) + return r.error(); + if (const auto r = FileSystem::Xattr::setxattr(fsPath, XATTR_SIZE, QByteArray::number(item._size)); !r) + return r.error(); + if (const auto r = FileSystem::Xattr::setxattr(fsPath, XATTR_MODTIME, QByteArray::number(static_cast(item._modtime))); !r) + return r.error(); + FileSystem::setModTime(fsPath, item._modtime); + return {}; +} + +bool VfsMacFileProvider::needsMetadataUpdate(const SyncFileItem &item) +{ + const auto path = params().root() / item.localName(); + if (!path.exists()) + return false; + const auto fsPath = path.get(); + if (!hasPlaceholderXattr(fsPath)) + return false; + const auto sizeAttr = FileSystem::Xattr::getxattr(fsPath, XATTR_SIZE); + const auto modAttr = FileSystem::Xattr::getxattr(fsPath, XATTR_MODTIME); + if (!sizeAttr || sizeAttr->toLongLong() != item._size) + return true; + if (!modAttr || modAttr->toLongLong() != static_cast(item._modtime)) + return true; + return false; +} + +bool VfsMacFileProvider::isDehydratedPlaceholder(const QString &filePath) +{ + return hasPlaceholderXattr(FileSystem::toFilesystemPath(filePath)); +} + +LocalInfo VfsMacFileProvider::statTypeVirtualFile(const std::filesystem::directory_entry &path, ItemType type) +{ + if (type != ItemTypeFile) + return LocalInfo(path, type); + if (!hasPlaceholderXattr(path.path())) + return LocalInfo(path, type); + const auto pin = getPinStateXattr(path.path()); + if (pin == PinState::AlwaysLocal) + return LocalInfo(path, ItemTypeVirtualFileDownload); + return LocalInfo(path, ItemTypeVirtualFile); +} + +bool VfsMacFileProvider::setPinState(const QString &relFilePath, PinState state) +{ + const auto localPath = params().root() / relFilePath; + if (!localPath.exists()) { + qCWarning(lcVfsFileProvider) << "setPinState: path does not exist" << localPath.toString(); + return false; + } + return setPinStateXattr(localPath.get(), state); +} + +Optional VfsMacFileProvider::pinState(const QString &relFilePath) +{ + const auto localPath = params().root() / relFilePath; + if (!localPath.exists()) + return {}; + return getPinStateXattr(localPath.get()); +} + +Vfs::AvailabilityResult VfsMacFileProvider::availability(const QString &folderPath) +{ + const auto localPath = params().root() / folderPath; + if (!localPath.exists()) + return AvailabilityError::NoSuchItem; + const auto pin = getPinStateXattr(localPath.get()); + if (pin == PinState::AlwaysLocal) + return VfsItemAvailability::AlwaysLocal; + if (pin == PinState::OnlineOnly) + return VfsItemAvailability::OnlineOnly; + if (hasPlaceholderXattr(localPath.get())) + return VfsItemAvailability::AllDehydrated; + return VfsItemAvailability::Mixed; +} + +HydrationJob *VfsMacFileProvider::hydrateFile(const QByteArray &fileId, const QString &targetPath) +{ + qCInfo(lcVfsFileProvider) << "Requesting hydration for" << fileId; + if (_hydrationJobs.contains(fileId)) { + qCWarning(lcVfsFileProvider) << "Ignoring hydration request, already running for fileId" << fileId; + return nullptr; + } + if (!isDehydratedPlaceholder(targetPath)) { + qCWarning(lcVfsFileProvider) << "Path is not a placeholder:" << targetPath; + return nullptr; + } + auto *hydration = new HydrationJob(this, fileId, std::make_unique(targetPath), nullptr); + hydration->setTargetFile(targetPath); + _hydrationJobs.insert(fileId, hydration); + connect(hydration, &HydrationJob::finished, this, &VfsMacFileProvider::slotHydrateJobFinished); + connect(hydration, &HydrationJob::error, this, [this, hydration](const QString &error) { + qCWarning(lcVfsFileProvider) << "Hydration failed:" << error; + _hydrationJobs.remove(hydration->fileId()); + hydration->deleteLater(); + }); + return hydration; +} + +void VfsMacFileProvider::slotHydrateJobFinished() +{ + auto *hydration = qobject_cast(sender()); + if (!hydration) + return; + qCInfo(lcVfsFileProvider) << "Hydration finished for" << hydration->targetFileName(); + const auto targetPath = FileSystem::toFilesystemPath(hydration->targetFileName()); + if (std::filesystem::exists(targetPath)) { + auto item = SyncFileItem::fromSyncJournalFileRecord(hydration->record()); + item->_type = ItemTypeFile; + if (auto inode = FileSystem::getInode(targetPath)) + item->_inode = inode.value(); + const auto result = params().journal->setFileRecord(SyncJournalFileRecord::fromSyncFileItem(*item)); + if (!result) + qCWarning(lcVfsFileProvider) << "Error updating file record after hydration:" << result.error(); + if (FileSystem::Xattr::removexattr(targetPath, XATTR_PLACEHOLDER)) { } + if (FileSystem::Xattr::removexattr(targetPath, XATTR_FILE_ID)) { } + if (FileSystem::Xattr::removexattr(targetPath, XATTR_SIZE)) { } + if (FileSystem::Xattr::removexattr(targetPath, XATTR_MODTIME)) { } + } + _hydrationJobs.remove(hydration->fileId()); + hydration->deleteLater(); +} + +void VfsMacFileProvider::fileStatusChanged(const QString &systemFileName, SyncFileStatus fileStatus) +{ + if (fileStatus.tag() != SyncFileStatus::StatusExcluded) + return; + const auto absPath = FileSystem::toFilesystemPath(systemFileName); + const auto rootPath = params().root().get(); + std::error_code ec; + const auto relPath = std::filesystem::relative(absPath, rootPath, ec); + if (ec || relPath.empty()) + return; + setPinState(QString::fromStdString(relPath.generic_string()), PinState::Excluded); +} + +Result VfsMacFileProvider::updateMetadata( + const SyncFileItem &item, const QString &filePath, const QString &replacesFile) +{ + if (item._type == ItemTypeVirtualFileDehydration) { + if (const auto r = createPlaceholder(item); !r) + return r.error(); + return ConvertToPlaceholderResult::Ok; + } + const auto fsPath = FileSystem::toFilesystemPath(filePath); + if (!hasPlaceholderXattr(fsPath)) + return ConvertToPlaceholderResult::Ok; + if (const auto r = FileSystem::Xattr::setxattr(fsPath, XATTR_SIZE, QByteArray::number(item._size)); !r) + return r.error(); + if (const auto r = FileSystem::Xattr::setxattr(fsPath, XATTR_MODTIME, QByteArray::number(static_cast(item._modtime))); !r) + return r.error(); + FileSystem::setModTime(fsPath, item._modtime); + return ConvertToPlaceholderResult::Ok; +} + +void VfsMacFileProvider::startImpl(const VfsSetupParams ¶ms) +{ + Q_UNUSED(params); + Q_EMIT started(); +} + +Result FileProviderVfsPluginFactory::prepare(const QString &path, const QUuid &accountUuid) const +{ + Q_UNUSED(accountUuid); + const auto canonicalPath = FileSystem::canonicalPath(path); + const auto fsPath = FileSystem::toFilesystemPath(canonicalPath); + if (fsPath.empty()) { + return tr("The path is not valid."); + } + if (!FileSystem::Xattr::supportsxattr(fsPath)) { + return tr("The filesystem for %1 does not support extended attributes. Files on Demand requires a filesystem with xattr support (e.g. APFS).") + .arg(path); + } + return {}; +} diff --git a/src/plugins/vfs/fileprovider/vfs_fileprovider.h b/src/plugins/vfs/fileprovider/vfs_fileprovider.h new file mode 100644 index 0000000000..1918a731ea --- /dev/null +++ b/src/plugins/vfs/fileprovider/vfs_fileprovider.h @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2025 OpenCloud GmbH and OpenCloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +#pragma once + +#include "common/plugin.h" +#include "libsync/vfs/hydrationjob.h" +#include "libsync/vfs/vfs.h" + +#include + +namespace OCC { + +class VfsMacFileProvider : public Vfs +{ + Q_OBJECT + +public: + explicit VfsMacFileProvider(QObject *parent = nullptr); + ~VfsMacFileProvider() override; + + Mode mode() const override; + + void stop() override; + void unregisterFolder() override; + + bool socketApiPinStateActionsShown() const override; + + Result createPlaceholder(const SyncFileItem &item) override; + + bool needsMetadataUpdate(const SyncFileItem &item) override; + bool isDehydratedPlaceholder(const QString &filePath) override; + LocalInfo statTypeVirtualFile(const std::filesystem::directory_entry &path, ItemType type) override; + + bool setPinState(const QString &relFilePath, PinState state) override; + Optional pinState(const QString &relFilePath) override; + AvailabilityResult availability(const QString &folderPath) override; + + HydrationJob *hydrateFile(const QByteArray &fileId, const QString &targetPath) override; + +public Q_SLOTS: + void fileStatusChanged(const QString &systemFileName, SyncFileStatus fileStatus) override; + +private Q_SLOTS: + void slotHydrateJobFinished(); + +protected: + Result updateMetadata(const SyncFileItem &item, const QString &filePath, const QString &replacesFile) override; + void startImpl(const VfsSetupParams ¶ms) override; + +private: + QMap _hydrationJobs; +}; + +class FileProviderVfsPluginFactory : public QObject, public DefaultPluginFactory +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "eu.opencloud.PluginFactory" FILE "libsync/vfs/vfspluginmetadata.json") + Q_INTERFACES(OCC::PluginFactory) +public: + Result prepare(const QString &path, const QUuid &accountUuid) const override; +}; + +} // namespace OCC