diff --git a/CMakeLists.txt b/CMakeLists.txt index 7d8bcfe124..b3a1e7cdab 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,8 +108,14 @@ 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" ) + set( APPLE_DEVELOPMENT_TEAM "" CACHE STRING "Apple Development Team ID used for code signing and App Group identifiers" ) 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..56638f4b6c --- /dev/null +++ b/src/extensions/fileprovider/CMakeLists.txt @@ -0,0 +1,127 @@ +# 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) + + # 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 + 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_BINARY_DIR}/OpenCloudFileProvider.entitlements" + XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "$(CODE_SIGN_IDENTITY)" + 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 + -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..1de96b55bc --- /dev/null +++ b/src/extensions/fileprovider/FileProviderXPCService.h @@ -0,0 +1,144 @@ +// 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. +/// 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. +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..d275c78f2a --- /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 + ${APPLE_DEVELOPMENT_TEAM}.${APPLICATION_REV_DOMAIN} + 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..a34c429901 --- /dev/null +++ b/src/extensions/fileprovider/OpenCloudFileProvider.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + @APP_GROUP_IDENTIFIER@ + + 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..ea8c34f0f0 --- /dev/null +++ b/src/extensions/fileprovider/OpenCloudFileProvider.entitlements.in @@ -0,0 +1,21 @@ + + + + + + com.apple.security.application-groups + + ${APPLE_DEVELOPMENT_TEAM}.${APPLICATION_REV_DOMAIN} + + com.apple.developer.fileprovider.server-capability + + keychain-access-groups + + $(AppIdentifierPrefix)${APPLICATION_REV_DOMAIN} + + + 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..a6b0417b02 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 "${APPLE_DEVELOPMENT_TEAM}" + 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..502ecc9dc9 --- /dev/null +++ b/src/plugins/vfs/nsfp/CMakeLists.txt @@ -0,0 +1,30 @@ +# CMake build configuration for the macOS NSFileProvider VFS plugin (nsfp). +# 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 + nsfpxpchandler.mm + vfs_nsfp.mm + LIBS + "-framework Foundation" + "-framework FileProvider" + "-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) + + # 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