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/OpenCloud.entitlements b/src/OpenCloud.entitlements new file mode 100644 index 0000000000..25a8ac63dc --- /dev/null +++ b/src/OpenCloud.entitlements @@ -0,0 +1,20 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + @APP_GROUP_IDENTIFIER@ + + com.apple.security.cs.disable-library-validation + + com.apple.security.cs.jit + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + + diff --git a/src/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/cmd/nsfpdiagnostic.h b/src/cmd/nsfpdiagnostic.h new file mode 100644 index 0000000000..aa0b766381 --- /dev/null +++ b/src/cmd/nsfpdiagnostic.h @@ -0,0 +1,17 @@ +// nsfpdiagnostic -- Diagnostic utility for dumping NSFileProvider domain information. +// Invoked via the --dump-nsfp-domains CLI flag on macOS. +// 2026-03-07: Initial creation (VOD-027). +#pragma once + +#ifdef Q_OS_MACOS + +namespace OCC { + +/// Queries all registered NSFileProvider domains and prints their details +/// (identifier, display name, path) to stdout. Blocks the calling thread +/// until the query completes via a semaphore. Returns 0 on success, 1 on error. +int dumpNSFileProviderDomains(); + +} // namespace OCC + +#endif // Q_OS_MACOS diff --git a/src/cmd/nsfpdiagnostic.mm b/src/cmd/nsfpdiagnostic.mm new file mode 100644 index 0000000000..8361f62a55 --- /dev/null +++ b/src/cmd/nsfpdiagnostic.mm @@ -0,0 +1,95 @@ +// nsfpdiagnostic -- Dumps registered NSFileProvider domains to stdout. +// Used for developer diagnostics via the --dump-nsfp-domains CLI flag. +// 2026-03-07: Initial creation (VOD-027). + +#import +#import + +#include +#include + +namespace OCC { + +int dumpNSFileProviderDomains() +{ + if (@available(macOS 11.0, *)) { + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + __block int result = 0; + + [NSFileProviderManager getDomainsWithCompletionHandler:^(NSArray *domains, + NSError *error) { + if (error) { + std::cerr << "Error querying NSFileProvider domains: " + << error.localizedDescription.UTF8String << std::endl; + result = 1; + dispatch_semaphore_signal(semaphore); + return; + } + + if (domains.count == 0) { + std::cout << "No NSFileProvider domains registered." << std::endl; + dispatch_semaphore_signal(semaphore); + return; + } + + std::cout << "NSFileProvider Domains:" << std::endl; + + for (NSFileProviderDomain *domain in domains) { + std::string separator(41, '-'); + std::cout << separator << std::endl; + + std::cout << "Identifier: " + << (domain.identifier ? domain.identifier.UTF8String : "(null)") + << std::endl; + + std::cout << "Display Name: " + << (domain.displayName ? domain.displayName.UTF8String : "(null)") + << std::endl; + + // Retrieve the user-visible URL for this domain's root. + NSFileProviderManager *manager = + [NSFileProviderManager managerForDomain:domain]; + if (manager) { + dispatch_semaphore_t urlSemaphore = dispatch_semaphore_create(0); + __block NSString *pathString = nil; + + [manager getUserVisibleURLForItemIdentifier:NSFileProviderRootContainerItemIdentifier + completionHandler:^(NSURL *url, NSError *urlError) { + if (url) { + pathString = [url.path copy]; + } else if (urlError) { + pathString = [NSString stringWithFormat:@"(error: %@)", + urlError.localizedDescription]; + } + dispatch_semaphore_signal(urlSemaphore); + }]; + + dispatch_semaphore_wait(urlSemaphore, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(5 * NSEC_PER_SEC))); + + std::cout << "Path: " + << (pathString ? pathString.UTF8String : "(unavailable)") + << std::endl; + } else { + std::cout << "Path: (no manager available)" << std::endl; + } + } + + std::string separator(41, '-'); + std::cout << separator << std::endl; + std::cout << "Total: " << domains.count << " domain(s)" << std::endl; + + dispatch_semaphore_signal(semaphore); + }]; + + // Wait up to 30 seconds for the async query to complete. + dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(30 * NSEC_PER_SEC))); + return result; + } else { + std::cerr << "NSFileProvider domains require macOS 11.0 or later." << std::endl; + return 1; + } +} + +} // namespace OCC diff --git a/src/extensions/fileprovider/CMakeLists.txt b/src/extensions/fileprovider/CMakeLists.txt new file mode 100644 index 0000000000..166b46b748 --- /dev/null +++ b/src/extensions/fileprovider/CMakeLists.txt @@ -0,0 +1,135 @@ +# 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 + FileProviderWebDAV.mm + FileProviderItemCache.mm + FileProviderConfig.mm + FileProviderWorkingSetDelta.mm + ) + + set(FILEPROVIDER_EXTENSION_HEADERS + OpenCloudFileProviderExtension.h + FileProviderXPCService.h + FileProviderItem.h + FileProviderEnumerator.h + FileProviderThumbnails.h + FileProviderWebDAV.h + FileProviderItemCache.h + FileProviderConfig.h + FileProviderWorkingSetDelta.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_BINARY_DIR}/OpenCloudFileProvider.entitlements" + "$" + COMMAND codesign --force --sign "${CODESIGN_IDENTITY}" + --entitlements "${CMAKE_CURRENT_BINARY_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/FileProviderConfig.h b/src/extensions/fileprovider/FileProviderConfig.h new file mode 100644 index 0000000000..e8513a670e --- /dev/null +++ b/src/extensions/fileprovider/FileProviderConfig.h @@ -0,0 +1,19 @@ +// FileProviderConfig -- reads the per-domain server config (davUrl + access +// token) that the main app writes into the App Group container. +#pragma once + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FileProviderConfig : NSObject + +/// Space DAV base URL for the domain (config key "davUrl"), or nil. ++ (nullable NSString *)davBaseForDomainIdentifier:(NSString *)domainId; + +/// Current OAuth bearer token for the domain (config key "accessToken"), or nil. ++ (nullable NSString *)accessTokenForDomainIdentifier:(NSString *)domainId; + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/extensions/fileprovider/FileProviderConfig.mm b/src/extensions/fileprovider/FileProviderConfig.mm new file mode 100644 index 0000000000..a2be1ed0e2 --- /dev/null +++ b/src/extensions/fileprovider/FileProviderConfig.mm @@ -0,0 +1,38 @@ +// FileProviderConfig -- see header. + +#import "FileProviderConfig.h" +#import "FileProviderXPCService.h" // kOpenCloudAppGroupIdentifier + +@implementation FileProviderConfig + +/// Loads the per-domain config plist (legacy global fallback) from the App Group. ++ (NSDictionary *)configForDomainIdentifier:(NSString *)domainId { + NSURL *container = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!container) return nil; + + NSString *perDomain = [NSString stringWithFormat:@"fileprovider_config_%@.plist", domainId]; + NSURL *perDomainURL = [container URLByAppendingPathComponent:perDomain]; + NSURL *url = [[NSFileManager defaultManager] fileExistsAtPath:perDomainURL.path] + ? perDomainURL + : [container URLByAppendingPathComponent:@"fileprovider_config.plist"]; + + NSData *data = [NSData dataWithContentsOfURL:url]; + if (!data) return nil; + NSDictionary *cfg = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListImmutable + format:nil error:nil]; + return [cfg isKindOfClass:[NSDictionary class]] ? cfg : nil; +} + ++ (NSString *)davBaseForDomainIdentifier:(NSString *)domainId { + NSString *dav = [self configForDomainIdentifier:domainId][@"davUrl"]; + return dav.length > 0 ? dav : nil; +} + ++ (NSString *)accessTokenForDomainIdentifier:(NSString *)domainId { + NSString *tok = [self configForDomainIdentifier:domainId][@"accessToken"]; + return tok.length > 0 ? tok : nil; +} + +@end diff --git a/src/extensions/fileprovider/FileProviderEnumerator.h b/src/extensions/fileprovider/FileProviderEnumerator.h new file mode 100644 index 0000000000..884b23b431 --- /dev/null +++ b/src/extensions/fileprovider/FileProviderEnumerator.h @@ -0,0 +1,30 @@ +// FileProviderEnumerator -- NSFileProviderEnumerator implementation that serves +// directory listings to Finder by querying the server live (PROPFIND Depth:1) +// and caching the result. The extension is the source of truth. +#pragma once + +#import +#import + +@class FileProviderItemCache; + +NS_ASSUME_NONNULL_BEGIN + +/// Enumerates items within a given container (directory) for the File Provider +/// framework. Resolves the container's server path from the shared cache, does a +/// live PROPFIND for its children, and updates the cache so deeper containers and +/// itemForIdentifier can resolve. +API_AVAILABLE(macos(12.0)) +@interface FileProviderEnumerator : NSObject + +/// Designated initializer. +/// @param containerId The container to enumerate (root, working set, or a folder's fileId). +/// @param domain The File Provider domain (its identifier selects the config plist). +/// @param cache Shared per-domain metadata cache (id->path, container snapshots). +- (instancetype)initWithContainerIdentifier:(NSFileProviderItemIdentifier)containerId + domain:(NSFileProviderDomain *)domain + cache:(FileProviderItemCache *)cache; + +@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..c4c12002df --- /dev/null +++ b/src/extensions/fileprovider/FileProviderEnumerator.mm @@ -0,0 +1,544 @@ +// FileProviderEnumerator -- serves directory listings to Finder by querying the +// server live (PROPFIND Depth:1). The extension is the source of truth; results +// are cached for itemForIdentifier, change detection and offline display. + +#import "FileProviderEnumerator.h" + +#import "FileProviderItem.h" +#import "FileProviderItemCache.h" +#import "FileProviderWebDAV.h" +#import "FileProviderConfig.h" +#import "FileProviderWorkingSetDelta.h" +#import "FileProviderXPCService.h" // kOpenCloudAppGroupIdentifier + +#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 for a domain (written by the main app's sync +/// engine after each sync). Used to drive the working-set change channel so that +/// items newly discovered on the server propagate to Finder. Returns nil if absent. +static NSArray *readSharedMetadata(NSFileProviderDomain *domain) { + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) return nil; + NSString *perDomain = [NSString stringWithFormat:@"fileprovider_items_%@.plist", domain.identifier]; + NSURL *url = [containerURL URLByAppendingPathComponent:perDomain]; + if (![[NSFileManager defaultManager] fileExistsAtPath:url.path]) { + url = [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; + } + NSData *data = [NSData dataWithContentsOfURL:url]; + if (!data) return nil; + NSArray *items = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListImmutable + format:nil error:nil]; + if (![items isKindOfClass:[NSArray class]]) return nil; + + // Filter out oCIS-internal items that the live PROPFIND enumeration does NOT + // return (e.g. the ".space" space-metadata marker). Reporting them in the + // working set while the per-folder PROPFIND enumeration omits them makes + // fileproviderd see an inconsistency → endless reconciliation (createItem + // ".space" loop) that blocks new creates and change propagation. + NSMutableArray *filtered = [NSMutableArray arrayWithCapacity:items.count]; + for (NSDictionary *dict in items) { + NSString *name = dict[@"filename"] ?: dict[@"name"] ?: @""; + if ([name isEqualToString:@".space"]) continue; + [filtered addObject:dict]; + } + return filtered; +} + +/// Cache key (not a real path) for the per-domain working-set snapshot. +static NSString *const kWorkingSetCacheKey = @"::workingset::"; + +/// oCIS-internal items that must NOT be shown to the user and that the main app's +/// sync does NOT put in the plist. The live PROPFIND returns ".space" at a space +/// root; if the folder enumeration shows it but the working set/plist omits it, +/// fileproviderd loops reconciliation (createItem ".space"). Filter it from BOTH +/// the PROPFIND folder enumeration and the plist so the two sources agree. +static BOOL isHiddenOcEntry(NSString *name) { + return [name isEqualToString:@".space"]; +} + +/// Content signature of the shared metadata, used as the working-set sync anchor +/// so fileproviderd re-checks when the main app's sync changes the plist. +/// Uses FPItemSetSignature (hash of every item's fileId|path|etag) rather than +/// count+max(modtime): a server-side rename keeps both count and modtime, so the +/// old signature collided and renames never re-enumerated. +static NSString *workingSetSignature(NSArray *allItems) { + return FPItemSetSignature(allItems); +} + +/// Content signature of a single folder's direct children FROM THE SHARED PLIST +/// (refreshed by the main app's sync). Used as the per-folder sync anchor so the +/// anchor CHANGES when the server adds/removes/renames a child — which is what +/// makes fileproviderd call enumerateChanges for that folder. (A cached PROPFIND +/// etag would never change on its own, so folders were never re-enumerated.) +static NSString *folderChildrenSignature(NSFileProviderDomain *domain, NSString *relPath) { + NSArray *all = readSharedMetadata(domain) ?: @[]; + NSString *target = relPath ?: @""; + NSMutableArray *children = [NSMutableArray array]; + for (NSDictionary *d in all) { + NSString *pp = d[@"parentPath"] ?: @""; + if ([pp isEqualToString:target]) [children addObject:d]; + } + return FPItemSetSignature(children); +} + +typedef NS_ENUM(NSInteger, FPContainerKind) { + FPContainerKindRoot, + FPContainerKindWorkingSet, + FPContainerKindTrash, + FPContainerKindFolder, + FPContainerKindUnknown, +}; + +/// Maps a raw PROPFIND/network error into a user-friendly NSFileProviderError that +/// Finder surfaces nicely (instead of e.g. "PROPFIND HTTP 401"). The common case is +/// that the OpenCloud app is closed, so its access token is no longer being refreshed. +static NSError *friendlyEnumerationError(NSError *err) { + BOOL isHTTP = [err.domain isEqualToString:@"FileProviderWebDAV"]; + NSInteger code = err.code; + + if (isHTTP && (code == 401 || code == 403)) { + return [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorNotAuthenticated + userInfo:@{NSLocalizedDescriptionKey: + NSLocalizedString(@"Bitte öffne die OpenCloud App und melde dich an, um auf deine Dateien zuzugreifen.", + @"FileProvider enumerate 401")}]; + } + if (isHTTP && code == 404) { + return [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorNoSuchItem + userInfo:@{NSLocalizedDescriptionKey: + NSLocalizedString(@"Dieser Ordner ist auf dem Server nicht mehr vorhanden.", + @"FileProvider enumerate 404")}]; + } + // Network failure / server not reachable / app not running. + return [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: + NSLocalizedString(@"OpenCloud ist gerade nicht erreichbar. Bitte öffne die OpenCloud App und versuche es erneut.", + @"FileProvider enumerate unreachable")}]; +} + +#pragma mark - FileProviderEnumerator + +API_AVAILABLE(macos(12.0)) +@implementation FileProviderEnumerator { + NSFileProviderItemIdentifier _containerId; + NSFileProviderDomain *_domain; + FileProviderItemCache *_cache; + BOOL _invalidated; +} + +- (instancetype)initWithContainerIdentifier:(NSFileProviderItemIdentifier)containerId + domain:(NSFileProviderDomain *)domain + cache:(FileProviderItemCache *)cache { + self = [super init]; + if (self) { + _containerId = [containerId copy]; + _domain = domain; + _cache = cache; + _invalidated = NO; + os_log_debug(enumeratorLog(), "Enumerator CREATED for container: %{public}@", containerId); + } + return self; +} + +#pragma mark - Container resolution + +/// Classifies the container and, for folders, resolves its space-relative path +/// (via the cache) and the identifier to report as children's parent. +- (FPContainerKind)kindForContainerRelPath:(NSString **)outRelPath + parentItemFileId:(NSString **)outFileId { + if ([_containerId isEqualToString:NSFileProviderRootContainerItemIdentifier]) { + if (outRelPath) *outRelPath = @""; + if (outFileId) *outFileId = NSFileProviderRootContainerItemIdentifier; + return FPContainerKindRoot; + } + if ([_containerId isEqualToString:NSFileProviderWorkingSetContainerItemIdentifier]) { + return FPContainerKindWorkingSet; + } + if ([_containerId isEqualToString:NSFileProviderTrashContainerItemIdentifier]) { + return FPContainerKindTrash; + } + NSString *path = [_cache pathForFileId:_containerId]; + if (path == nil) { + return FPContainerKindUnknown; + } + if (outRelPath) *outRelPath = path; + if (outFileId) *outFileId = _containerId; + return FPContainerKindFolder; +} + +/// Builds the metadata dict consumed by FileProviderItem from a parsed entry. +static NSDictionary *itemDictFromEntry(FileProviderWebDAVEntry *e, + NSString *parentFileId, + NSString *davBase) { + return @{ + @"fileId": e.fileId ?: @"", + @"filename": e.name ?: @"", + @"path": e.relativePath ?: @"", + @"parentId": parentFileId ?: NSFileProviderRootContainerItemIdentifier, + @"isDirectory": @(e.isDirectory), + @"size": @(e.size), + @"modtime": @(e.modtime), + @"etag": e.etag ?: @"", + @"isDownloaded": @(e.isDirectory ? YES : NO), + @"davUrl": davBase ?: @"", + }; +} + +#pragma mark - NSFileProviderEnumerator + +- (void)enumerateItemsForObserver:(id)observer + startingAtPage:(NSFileProviderPage)page { + if (_invalidated) { + [observer finishEnumeratingWithError: + [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: @"Enumerator invalidated"}]]; + return; + } + + NSString *relPath = nil; + NSString *parentFileId = nil; + FPContainerKind kind = [self kindForContainerRelPath:&relPath parentItemFileId:&parentFileId]; + + appendTrace([NSString stringWithFormat:@"[%@] enumerateItems container=%@ kind=%ld relPath=%@\n", + [NSDate date], _containerId, (long)kind, relPath ?: @"(nil)"]); + + if (kind == FPContainerKindTrash) { + [observer didEnumerateItems:@[]]; + [observer finishEnumeratingUpToPage:nil]; + return; + } + // Working set: seed from the shared plist (kept current by the main app's + // sync). This is the global item set fileproviderd tracks; the per-folder + // enumerators drive the live, browsable tree. + if (kind == FPContainerKindWorkingSet) { + NSArray *allItems = readSharedMetadata(_domain) ?: @[]; + NSMutableArray *items = [NSMutableArray array]; + for (NSDictionary *dict in allItems) { + [items addObject:[[FileProviderItem alloc] initWithDictionary:dict]]; + [_cache setMetadata:dict forFileId:dict[@"fileId"]]; + } + appendTrace([NSString stringWithFormat:@"[%@] enumerateItems WORKINGSET items=%lu\n", + [NSDate date], (unsigned long)items.count]); + [observer didEnumerateItems:items]; + [observer finishEnumeratingUpToPage:nil]; + return; + } + if (kind == FPContainerKindUnknown) { + os_log_error(enumeratorLog(), "enumerateItems: container %{public}@ not in cache", _containerId); + // Transient so fileproviderd retries after the parent is enumerated. + [observer finishEnumeratingWithError: + [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: @"Container not yet resolved"}]]; + return; + } + + NSString *domainId = _domain.identifier; + NSString *davBase = [FileProviderConfig davBaseForDomainIdentifier:domainId]; + NSString *token = [FileProviderConfig accessTokenForDomainIdentifier:domainId]; + + if (davBase.length == 0 || token.length == 0) { + // Not signed in yet: serve cached children if we have them, else error. + if (![self serveCachedChildrenForRelPath:relPath toObserver:observer]) { + [observer finishEnumeratingWithError: + [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorNotAuthenticated + userInfo:@{NSLocalizedDescriptionKey: + @"Bitte in der OpenCloud App anmelden."}]]; + } + return; + } + + // Cache-first: if we already have a live snapshot of this folder, show it + // instantly and refresh in the background, so Finder doesn't block on the + // network for folders that were opened before. The first visit is still + // served live (authoritative), so nothing goes missing. + NSArray *cachedChildIds = [_cache childFileIdsForContainerPath:relPath]; + NSString *prevEtag = [_cache etagForContainerPath:relPath] ?: @""; + BOOL alreadyServed = NO; + if (cachedChildIds.count > 0) { + [self serveCachedChildrenForRelPath:relPath toObserver:observer]; + alreadyServed = YES; + } + + __block FileProviderEnumerator *strongSelf = self; + [FileProviderWebDAV propfindChildrenAtDavBase:davBase + relativePath:relPath + token:token + completion:^(NSArray *entries, + NSError *error) { + if (error || entries == nil) { + os_log_error(enumeratorLog(), "enumerateItems PROPFIND failed for %{public}@: %{public}@", + relPath, error.localizedDescription); + if (alreadyServed) { + // Cached contents were already shown; ignore the transient error. + strongSelf = nil; + return; + } + // Offline / transient: fall back to cached children. + if (![strongSelf serveCachedChildrenForRelPath:relPath toObserver:observer]) { + [observer finishEnumeratingWithError:friendlyEnumerationError(error)]; + } + strongSelf = nil; + return; + } + + // Cache-first PROBE: when we already served from cache, do NOT mutate the + // cache here. Just check the folder's etag. If it changed (oCIS bumps the + // folder etag on ANY child add/remove/modify), ask Finder to re-enumerate + // — enumerateChanges then runs the authoritative diff, which needs the OLD + // cached child list as its baseline to detect DELETIONS. Overwriting the + // cache here would erase that baseline and a deleted file would linger. + if (alreadyServed) { + NSString *newEtag = @""; + for (FileProviderWebDAVEntry *e in entries) { + if ([e.relativePath isEqualToString:relPath]) { newEtag = e.etag ?: @""; break; } + } + BOOL changed = ![newEtag isEqualToString:prevEtag]; + appendTrace([NSString stringWithFormat:@"[%@] enumerateItems PROBE container=%@ changed=%d\n", + [NSDate date], strongSelf->_containerId, changed]); + if (changed) { + NSFileProviderManager *mgr = [NSFileProviderManager managerForDomain:strongSelf->_domain]; + [mgr signalEnumeratorForContainerItemIdentifier:strongSelf->_containerId + completionHandler:^(NSError *e) {}]; + } + strongSelf = nil; + return; + } + + // First visit: build the listing live, populate the cache, serve it. + NSMutableArray *items = [NSMutableArray array]; + NSMutableArray *childIds = [NSMutableArray array]; + NSString *selfEtag = @""; + + for (FileProviderWebDAVEntry *e in entries) { + // The folder itself (self): record its etag, do not list it as a child. + if ([e.relativePath isEqualToString:relPath]) { + selfEtag = e.etag ?: @""; + continue; + } + if (isHiddenOcEntry(e.name)) continue; // keep consistent with the plist + NSDictionary *dict = itemDictFromEntry(e, parentFileId, davBase); + [items addObject:[[FileProviderItem alloc] initWithDictionary:dict]]; + [childIds addObject:e.fileId ?: @""]; + [strongSelf->_cache setMetadata:dict forFileId:e.fileId]; + } + + [strongSelf->_cache setContainerPath:relPath etag:selfEtag childFileIds:childIds]; + [strongSelf->_cache save]; + + os_log_info(enumeratorLog(), "enumerateItems: %lu items for %{public}@", + (unsigned long)items.count, relPath); + appendTrace([NSString stringWithFormat:@"[%@] enumerateItems RESULT container=%@ items=%lu\n", + [NSDate date], strongSelf->_containerId, (unsigned long)items.count]); + + [observer didEnumerateItems:items]; + [observer finishEnumeratingUpToPage:nil]; + strongSelf = nil; + }]; +} + +/// Serves the last-known children of a folder from the cache (offline path). +/// Returns NO if nothing is cached. +- (BOOL)serveCachedChildrenForRelPath:(NSString *)relPath + toObserver:(id)observer { + NSArray *childIds = [_cache childFileIdsForContainerPath:relPath]; + if (childIds.count == 0) return NO; + + NSMutableArray *items = [NSMutableArray array]; + for (NSString *fid in childIds) { + NSDictionary *md = [_cache metadataForFileId:fid]; + if (md) [items addObject:[[FileProviderItem alloc] initWithDictionary:md]]; + } + [observer didEnumerateItems:items]; + [observer finishEnumeratingUpToPage:nil]; + os_log_info(enumeratorLog(), "enumerateItems: served %lu cached items for %{public}@", + (unsigned long)items.count, relPath); + return YES; +} + +- (void)enumerateChangesForObserver:(id)observer + fromSyncAnchor:(NSFileProviderSyncAnchor)anchor { + if (_invalidated) { + [observer finishEnumeratingWithError: + [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: @"Enumerator invalidated"}]]; + return; + } + + NSString *relPath = nil; + NSString *parentFileId = nil; + FPContainerKind kind = [self kindForContainerRelPath:&relPath parentItemFileId:&parentFileId]; + + // Working set: the global change channel. Diff the shared plist (refreshed by + // the main app's sync) against the last snapshot so server-side additions and + // deletions propagate to Finder. + if (kind == FPContainerKindWorkingSet) { + NSArray *allItems = readSharedMetadata(_domain) ?: @[]; + + // Diff against the previous snapshot so only genuinely new/changed items + // are reported. Reporting every item on every pass made fileproviderd + // re-index the whole tree every ~30s (huge CPU + constant Finder view + // churn). Build the previous etag map from the cache BEFORE overwriting. + NSArray *prevIds = [_cache childFileIdsForContainerPath:kWorkingSetCacheKey] ?: @[]; + NSMutableArray *previousItems = + [NSMutableArray arrayWithCapacity:prevIds.count]; + for (NSString *fid in prevIds) { + NSDictionary *md = [_cache metadataForFileId:fid]; + if (md) [previousItems addObject:md]; + } + + FPWorkingSetDelta *delta = FPComputeWorkingSetDelta(allItems, previousItems); + + // Refresh the cache for all current items (path/parent lookups stay current). + for (NSDictionary *dict in allItems) { + [_cache setMetadata:dict forFileId:dict[@"fileId"]]; + } + + if (delta.deletedFileIds.count > 0) { + [observer didDeleteItemsWithIdentifiers:delta.deletedFileIds]; + } + if (delta.changedItems.count > 0) { + NSMutableArray *updated = + [NSMutableArray arrayWithCapacity:delta.changedItems.count]; + for (NSDictionary *dict in delta.changedItems) { + [updated addObject:[[FileProviderItem alloc] initWithDictionary:dict]]; + } + [observer didUpdateItems:updated]; + } + + NSString *sig = workingSetSignature(allItems); + [_cache setContainerPath:kWorkingSetCacheKey etag:sig childFileIds:delta.currentFileIds]; + [_cache save]; + appendTrace([NSString stringWithFormat:@"[%@] enumerateChanges WORKINGSET upd=%lu del=%lu (of %lu)\n", + [NSDate date], (unsigned long)delta.changedItems.count, + (unsigned long)delta.deletedFileIds.count, (unsigned long)allItems.count]); + [observer finishEnumeratingChangesUpToSyncAnchor:[self anchorData:sig] moreComing:NO]; + return; + } + // Trash / unknown: report no changes. + if (kind != FPContainerKindRoot && kind != FPContainerKindFolder) { + [observer finishEnumeratingChangesUpToSyncAnchor:[self anchorData:@""] moreComing:NO]; + return; + } + + NSString *domainId = _domain.identifier; + NSString *davBase = [FileProviderConfig davBaseForDomainIdentifier:domainId]; + NSString *token = [FileProviderConfig accessTokenForDomainIdentifier:domainId]; + if (davBase.length == 0 || token.length == 0) { + [observer finishEnumeratingChangesUpToSyncAnchor: + [self anchorData:folderChildrenSignature(_domain, relPath)] moreComing:NO]; + return; + } + + NSArray *previousChildIds = [_cache childFileIdsForContainerPath:relPath] ?: @[]; + + __block FileProviderEnumerator *strongSelf = self; + [FileProviderWebDAV propfindChildrenAtDavBase:davBase + relativePath:relPath + token:token + completion:^(NSArray *entries, + NSError *error) { + if (error || entries == nil) { + // Keep the existing anchor; try again on the next signal. + [observer finishEnumeratingChangesUpToSyncAnchor: + [strongSelf anchorData:folderChildrenSignature(strongSelf->_domain, relPath)] moreComing:NO]; + strongSelf = nil; + return; + } + + NSMutableArray *updated = [NSMutableArray array]; + NSMutableArray *currentIds = [NSMutableArray array]; + NSString *selfEtag = @""; + + for (FileProviderWebDAVEntry *e in entries) { + if ([e.relativePath isEqualToString:relPath]) { selfEtag = e.etag ?: @""; continue; } + if (isHiddenOcEntry(e.name)) continue; // keep consistent with the plist + NSDictionary *dict = itemDictFromEntry(e, parentFileId, davBase); + [updated addObject:[[FileProviderItem alloc] initWithDictionary:dict]]; + [currentIds addObject:e.fileId ?: @""]; + [strongSelf->_cache setMetadata:dict forFileId:e.fileId]; + } + + // Deletions: previously-known children no longer present. + NSMutableSet *deleted = [NSMutableSet setWithArray:previousChildIds]; + [deleted minusSet:[NSSet setWithArray:currentIds]]; + if (deleted.count > 0) { + [observer didDeleteItemsWithIdentifiers:[deleted allObjects]]; + } + if (updated.count > 0) { + [observer didUpdateItems:updated]; + } + + [strongSelf->_cache setContainerPath:relPath etag:selfEtag childFileIds:currentIds]; + [strongSelf->_cache save]; + + NSString *folderAnchor = folderChildrenSignature(strongSelf->_domain, relPath); + appendTrace([NSString stringWithFormat:@"[%@] enumerateChanges RESULT container=%@ upd=%lu del=%lu anchor=%@\n", + [NSDate date], strongSelf->_containerId, + (unsigned long)updated.count, (unsigned long)deleted.count, folderAnchor]); + + [observer finishEnumeratingChangesUpToSyncAnchor:[strongSelf anchorData:folderAnchor] moreComing:NO]; + strongSelf = nil; + }]; +} + +- (NSData *)anchorData:(NSString *)s { + return [(s ?: @"") dataUsingEncoding:NSUTF8StringEncoding]; +} + +- (void)currentSyncAnchorWithCompletionHandler:(void (^)(NSFileProviderSyncAnchor _Nullable))completionHandler { + NSString *relPath = nil; + FPContainerKind kind = [self kindForContainerRelPath:&relPath parentItemFileId:nil]; + NSString *anchor; + if (kind == FPContainerKindWorkingSet) { + // Content signature of the shared plist so the anchor changes whenever the + // main app's sync adds/removes items → fileproviderd calls enumerateChanges. + anchor = workingSetSignature(readSharedMetadata(_domain) ?: @[]); + } else if (kind == FPContainerKindRoot || kind == FPContainerKindFolder) { + // Per-folder child signature from the plist: changes when a child is added + // or removed on the server → fileproviderd re-enumerates this folder. + anchor = folderChildrenSignature(_domain, relPath); + } else { + anchor = @""; + } + completionHandler([self anchorData:anchor]); +} + +- (void)invalidate { + os_log_info(enumeratorLog(), "Enumerator invalidated for container: %{public}@", _containerId); + _invalidated = YES; +} + +@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..2799ffcdd3 --- /dev/null +++ b/src/extensions/fileprovider/FileProviderItem.mm @@ -0,0 +1,243 @@ +// 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; + + // NSFileProviderItemVersion is required for replicated extensions, and + // fileproviderd IGNORES a didUpdateItems whose version is unchanged. The + // metadataVersion therefore MUST change on any metadata change — crucially + // a RENAME or MOVE, which on OCIS keeps the modtime. A modtime-only version + // made Finder silently drop server-side renames. So derive: + // contentVersion := modtime (content changes bump modtime) + // metadataVersion := hash(filename | parentId | size | modtime) + int64_t epoch = date ? (int64_t)[date timeIntervalSince1970] : 0; + NSData *contentVersion = [NSData dataWithBytes:&epoch length:sizeof(epoch)]; + + NSString *metaString = [NSString stringWithFormat:@"%@|%@|%@|%lld", + _filename ?: @"", _parentItemIdentifier ?: @"", _documentSize ?: @0, epoch]; + NSData *metadataVersion = [metaString dataUsingEncoding:NSUTF8StringEncoding]; + + _itemVersion = [[NSFileProviderItemVersion alloc] initWithContentVersion:contentVersion + metadataVersion:metadataVersion]; + + 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 + | NSFileProviderItemCapabilitiesAllowsReparenting + | 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/FileProviderItemCache.h b/src/extensions/fileprovider/FileProviderItemCache.h new file mode 100644 index 0000000000..e8609f777e --- /dev/null +++ b/src/extensions/fileprovider/FileProviderItemCache.h @@ -0,0 +1,51 @@ +// FileProviderItemCache -- persistent metadata cache for the File Provider +// extension's authoritative (server-driven) enumeration. +// +// Stores: +// * fileId -> space-relative path (resolve a container id to a path) +// * containerPath -> { etag, childIds } (per-folder change detection + offline) +// +// Backed by a plist at an injectable file URL so it can be unit-tested with a +// temp directory (see tests/test_fileprovider_item_cache.mm). Thread-safe. +#pragma once + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FileProviderItemCache : NSObject + +/// Loads any existing cache from `url`. Mutations are kept in memory until +/// -save is called. +- (instancetype)initWithFileURL:(NSURL *)url; + +#pragma mark id <-> path + +- (nullable NSString *)pathForFileId:(NSString *)fileId; +- (void)setPath:(NSString *)path forFileId:(NSString *)fileId; + +#pragma mark per-item metadata (for itemForIdentifier / offline) + +/// Full metadata dict for an item (keys as consumed by FileProviderItem: +/// fileId, filename, path, parentId, isDirectory, size, modtime, etag, +/// isDownloaded, davUrl). Returns nil if unknown. +- (nullable NSDictionary *)metadataForFileId:(NSString *)fileId; +/// Stores item metadata and (for convenience) its fileId->path mapping. +- (void)setMetadata:(NSDictionary *)metadata forFileId:(NSString *)fileId; + +#pragma mark container snapshot (change detection / offline) + +- (nullable NSString *)etagForContainerPath:(NSString *)containerPath; +- (nullable NSArray *)childFileIdsForContainerPath:(NSString *)containerPath; +- (void)setContainerPath:(NSString *)containerPath etag:(NSString *)etag childFileIds:(NSArray *)childFileIds; + +#pragma mark persistence + +/// Atomically writes the in-memory state to the file URL. Returns NO on failure. +- (BOOL)save; +/// Discards in-memory state and re-reads from the file URL. +- (void)reload; + +@end + +NS_ASSUME_NONNULL_END \ No newline at end of file diff --git a/src/extensions/fileprovider/FileProviderItemCache.mm b/src/extensions/fileprovider/FileProviderItemCache.mm new file mode 100644 index 0000000000..6e8045115f --- /dev/null +++ b/src/extensions/fileprovider/FileProviderItemCache.mm @@ -0,0 +1,136 @@ +// FileProviderItemCache -- see header. + +#import "FileProviderItemCache.h" + +@implementation FileProviderItemCache { + NSURL *_url; + NSLock *_lock; + // fileId -> relative path + NSMutableDictionary *_idToPath; + // container path -> { @"etag": NSString, @"children": NSArray } + NSMutableDictionary *_containers; + // fileId -> full item metadata dict + NSMutableDictionary *_metadata; +} + +- (instancetype)initWithFileURL:(NSURL *)url { + if ((self = [super init])) { + _url = url; + _lock = [[NSLock alloc] init]; + _idToPath = [NSMutableDictionary dictionary]; + _containers = [NSMutableDictionary dictionary]; + _metadata = [NSMutableDictionary dictionary]; + [self reload]; + } + return self; +} + +#pragma mark id <-> path + +- (NSString *)pathForFileId:(NSString *)fileId { + if (fileId.length == 0) return nil; + [_lock lock]; + NSString *p = _idToPath[fileId]; + [_lock unlock]; + return p; +} + +- (void)setPath:(NSString *)path forFileId:(NSString *)fileId { + if (fileId.length == 0 || path == nil) return; + [_lock lock]; + _idToPath[fileId] = path; + [_lock unlock]; +} + +- (NSDictionary *)metadataForFileId:(NSString *)fileId { + if (fileId.length == 0) return nil; + [_lock lock]; + NSDictionary *m = _metadata[fileId]; + [_lock unlock]; + return m; +} + +- (void)setMetadata:(NSDictionary *)metadata forFileId:(NSString *)fileId { + if (fileId.length == 0 || metadata == nil) return; + [_lock lock]; + _metadata[fileId] = metadata; + NSString *path = metadata[@"path"]; + if ([path isKindOfClass:[NSString class]]) { + _idToPath[fileId] = path; + } + [_lock unlock]; +} + +#pragma mark container snapshot + +- (NSString *)etagForContainerPath:(NSString *)containerPath { + if (containerPath == nil) return nil; + [_lock lock]; + NSString *e = _containers[containerPath][@"etag"]; + [_lock unlock]; + return e; +} + +- (NSArray *)childFileIdsForContainerPath:(NSString *)containerPath { + if (containerPath == nil) return nil; + [_lock lock]; + NSArray *c = _containers[containerPath][@"children"]; + [_lock unlock]; + return c; +} + +- (void)setContainerPath:(NSString *)containerPath + etag:(NSString *)etag + childFileIds:(NSArray *)childFileIds { + if (containerPath == nil) return; + [_lock lock]; + _containers[containerPath] = @{ + @"etag": etag ?: @"", + @"children": childFileIds ?: @[], + }; + [_lock unlock]; +} + +#pragma mark persistence + +- (BOOL)save { + [_lock lock]; + NSDictionary *root = @{ + @"idToPath": [_idToPath copy], + @"containers": [_containers copy], + @"metadata": [_metadata copy], + }; + [_lock unlock]; + + NSError *err = nil; + NSData *data = [NSPropertyListSerialization dataWithPropertyList:root + format:NSPropertyListBinaryFormat_v1_0 + options:0 + error:&err]; + if (!data) return NO; + return [data writeToURL:_url options:NSDataWritingAtomic error:&err]; +} + +- (void)reload { + NSData *data = [NSData dataWithContentsOfURL:_url]; + [_lock lock]; + [_idToPath removeAllObjects]; + [_containers removeAllObjects]; + [_metadata removeAllObjects]; + if (data) { + NSDictionary *root = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListImmutable + format:nil error:nil]; + if ([root isKindOfClass:[NSDictionary class]]) { + NSDictionary *i2p = root[@"idToPath"]; + NSDictionary *cnt = root[@"containers"]; + NSDictionary *md = root[@"metadata"]; + if ([i2p isKindOfClass:[NSDictionary class]]) [_idToPath addEntriesFromDictionary:i2p]; + if ([cnt isKindOfClass:[NSDictionary class]]) [_containers addEntriesFromDictionary:cnt]; + if ([md isKindOfClass:[NSDictionary class]]) [_metadata addEntriesFromDictionary:md]; + } + } + [_lock unlock]; +} + +@end \ No newline at end of file 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/FileProviderWebDAV.h b/src/extensions/fileprovider/FileProviderWebDAV.h new file mode 100644 index 0000000000..69ede18556 --- /dev/null +++ b/src/extensions/fileprovider/FileProviderWebDAV.h @@ -0,0 +1,62 @@ +// FileProviderWebDAV -- WebDAV PROPFIND multistatus parser for the macOS +// File Provider extension. Self-contained: depends only on Foundation so it +// can be unit-tested standalone (see tests/test_fileprovider_webdav.mm). +#pragma once + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// One entry parsed from a WebDAV PROPFIND multistatus response. +@interface FileProviderWebDAVEntry : NSObject + +/// Server file id (oc:fileid / oc:id). Empty string if absent. +@property (nonatomic, copy) NSString *fileId; +/// Path relative to the space root, percent-decoded, no leading/trailing slash. +/// The space root itself yields @"" (empty string). +@property (nonatomic, copy) NSString *relativePath; +/// Last path component (display name). Empty for the space root. +@property (nonatomic, copy) NSString *name; +/// YES if contains . +@property (nonatomic) BOOL isDirectory; +/// File size in bytes (0 for directories). +@property (nonatomic) int64_t size; +/// Last-modified time as seconds since 1970 (0 if absent/unparseable). +@property (nonatomic) int64_t modtime; +/// ETag with surrounding quotes stripped. Empty string if absent. +@property (nonatomic, copy) NSString *etag; +/// oc:permissions string (e.g. "RDNVWZP"). Empty string if absent. +@property (nonatomic, copy) NSString *permissions; + +@end + +@interface FileProviderWebDAV : NSObject + +/// Parses a PROPFIND multistatus XML body into entries. +/// +/// @param xml Raw multistatus XML data. +/// @param hrefPrefix The path portion of the space DAV URL (e.g. +/// "/dav/spaces/74351999-...$1651695e-..."), used to turn +/// absolute hrefs into space-relative paths. May be passed +/// with or without a trailing slash. +/// @param error Set on parse failure. +/// @return Array of entries (including the space-root self entry, relativePath +/// @""), or nil on error. ++ (nullable NSArray *)parseMultistatus:(NSData *)xml hrefPrefix:(NSString *)hrefPrefix error:(NSError **)error; + +/// Performs a live `PROPFIND Depth:1` against a folder and returns its entries +/// (the folder itself, relativePath @"", plus its immediate children). +/// +/// @param davBase Space DAV base URL (e.g. ".../dav/spaces/"), as +/// stored in the extension config plist (may be %-encoded). +/// @param relativePath Space-relative folder path (@"" for the space root). +/// @param token OAuth bearer token. +/// @param completion Called on a background queue with parsed entries or an error. ++ (void)propfindChildrenAtDavBase:(NSString *)davBase + relativePath:(NSString *)relativePath + token:(NSString *)token + completion:(void (^)(NSArray *_Nullable entries, NSError *_Nullable error))completion; + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/extensions/fileprovider/FileProviderWebDAV.mm b/src/extensions/fileprovider/FileProviderWebDAV.mm new file mode 100644 index 0000000000..45c1827fb0 --- /dev/null +++ b/src/extensions/fileprovider/FileProviderWebDAV.mm @@ -0,0 +1,244 @@ +// FileProviderWebDAV -- WebDAV PROPFIND multistatus parser. See header. + +#import "FileProviderWebDAV.h" + +static NSString *const kDAVNS = @"DAV:"; +static NSString *const kOCNS = @"http://owncloud.org/ns"; + +@implementation FileProviderWebDAVEntry +@end + +#pragma mark - SAX delegate + +@interface FPWebDAVParserDelegate : NSObject +@property (nonatomic, copy) NSString *hrefPrefix; // normalised, no trailing slash +@property (nonatomic, strong) NSMutableArray *entries; +@end + +@implementation FPWebDAVParserDelegate { + // Per-response state. + NSString *_href; + NSMutableDictionary *_committed; // props from 200 propstats + // Per-propstat state. + NSMutableDictionary *_tmp; + NSString *_propstatStatus; + BOOL _inResourcetype; + NSMutableString *_text; + NSDateFormatter *_httpDateFormatter; +} + +- (instancetype)init { + if ((self = [super init])) { + _entries = [NSMutableArray array]; + _text = [NSMutableString string]; + _httpDateFormatter = [[NSDateFormatter alloc] init]; + _httpDateFormatter.locale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"]; + _httpDateFormatter.timeZone = [NSTimeZone timeZoneForSecondsFromGMT:0]; + _httpDateFormatter.dateFormat = @"EEE, dd MMM yyyy HH:mm:ss 'GMT'"; + } + return self; +} + +- (int64_t)epochFromHTTPDate:(NSString *)s { + if (s.length == 0) return 0; + NSDate *d = [_httpDateFormatter dateFromString:s]; + return d ? (int64_t)d.timeIntervalSince1970 : 0; +} + +/// Turns an absolute-or-relative href into a space-relative, percent-decoded path. +/// The space root yields @"". +- (NSString *)relativePathForHref:(NSString *)href { + if (href.length == 0) return @""; + NSString *remainder = href; + NSRange r = [href rangeOfString:self.hrefPrefix]; + if (r.location != NSNotFound) { + remainder = [href substringFromIndex:r.location + r.length]; + } + // Trim surrounding slashes. + while ([remainder hasPrefix:@"/"]) remainder = [remainder substringFromIndex:1]; + while ([remainder hasSuffix:@"/"]) remainder = [remainder substringToIndex:remainder.length - 1]; + NSString *decoded = [remainder stringByRemovingPercentEncoding]; + return decoded ?: remainder; +} + +#pragma mark NSXMLParserDelegate + +- (void)parser:(NSXMLParser *)parser + didStartElement:(NSString *)elementName + namespaceURI:(NSString *)namespaceURI + qualifiedName:(NSString *)qName + attributes:(NSDictionary *)attributeDict { + [_text setString:@""]; + + if ([namespaceURI isEqualToString:kDAVNS] && [elementName isEqualToString:@"response"]) { + _href = nil; + _committed = [NSMutableDictionary dictionary]; + } else if ([namespaceURI isEqualToString:kDAVNS] && [elementName isEqualToString:@"propstat"]) { + _tmp = [NSMutableDictionary dictionary]; + _propstatStatus = nil; + } else if ([namespaceURI isEqualToString:kDAVNS] && [elementName isEqualToString:@"resourcetype"]) { + _inResourcetype = YES; + } else if (_inResourcetype && [namespaceURI isEqualToString:kDAVNS] + && [elementName isEqualToString:@"collection"]) { + _tmp[@"isDir"] = @YES; + } +} + +- (void)parser:(NSXMLParser *)parser foundCharacters:(NSString *)string { + [_text appendString:string]; +} + +- (void)parser:(NSXMLParser *)parser + didEndElement:(NSString *)elementName + namespaceURI:(NSString *)namespaceURI + qualifiedName:(NSString *)qName { + NSString *text = [_text stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + + if ([namespaceURI isEqualToString:kDAVNS] && [elementName isEqualToString:@"href"]) { + if (!_href) _href = text; // first href = the response's own href + } else if ([namespaceURI isEqualToString:kDAVNS] && [elementName isEqualToString:@"status"]) { + _propstatStatus = text; + } else if ([namespaceURI isEqualToString:kDAVNS] && [elementName isEqualToString:@"resourcetype"]) { + _inResourcetype = NO; + } else if ([namespaceURI isEqualToString:kOCNS] && [elementName isEqualToString:@"fileid"]) { + if (text.length) _tmp[@"fileId"] = text; // oc:fileid takes priority + } else if ([namespaceURI isEqualToString:kOCNS] && [elementName isEqualToString:@"id"]) { + if (text.length && !_tmp[@"fileId"]) _tmp[@"fileId"] = text; + } else if ([namespaceURI isEqualToString:kDAVNS] && [elementName isEqualToString:@"getetag"]) { + _tmp[@"etag"] = [text stringByTrimmingCharactersInSet: + [NSCharacterSet characterSetWithCharactersInString:@"\""]]; + } else if ([namespaceURI isEqualToString:kDAVNS] && [elementName isEqualToString:@"getcontentlength"]) { + _tmp[@"size"] = @([text longLongValue]); + } else if ([namespaceURI isEqualToString:kDAVNS] && [elementName isEqualToString:@"getlastmodified"]) { + _tmp[@"modtime"] = @([self epochFromHTTPDate:text]); + } else if ([namespaceURI isEqualToString:kOCNS] && [elementName isEqualToString:@"permissions"]) { + _tmp[@"permissions"] = text; + } else if ([namespaceURI isEqualToString:kDAVNS] && [elementName isEqualToString:@"propstat"]) { + // Commit props only from a 200 propstat. + if (_propstatStatus && [_propstatStatus rangeOfString:@" 200 "].location != NSNotFound) { + [_committed addEntriesFromDictionary:_tmp]; + } + _tmp = nil; + } else if ([namespaceURI isEqualToString:kDAVNS] && [elementName isEqualToString:@"response"]) { + FileProviderWebDAVEntry *e = [[FileProviderWebDAVEntry alloc] init]; + NSString *rel = [self relativePathForHref:_href]; + e.relativePath = rel; + NSRange slash = [rel rangeOfString:@"/" options:NSBackwardsSearch]; + e.name = (slash.location == NSNotFound) ? rel : [rel substringFromIndex:slash.location + 1]; + e.fileId = _committed[@"fileId"] ?: @""; + e.isDirectory = [_committed[@"isDir"] boolValue]; + e.size = [_committed[@"size"] longLongValue]; + e.modtime = [_committed[@"modtime"] longLongValue]; + e.etag = _committed[@"etag"] ?: @""; + e.permissions = _committed[@"permissions"] ?: @""; + [_entries addObject:e]; + _href = nil; + _committed = nil; + } +} + +@end + +#pragma mark - FileProviderWebDAV + +@implementation FileProviderWebDAV + ++ (NSArray *)parseMultistatus:(NSData *)xml + hrefPrefix:(NSString *)hrefPrefix + error:(NSError **)error { + if (xml.length == 0) { + if (error) { + *error = [NSError errorWithDomain:@"FileProviderWebDAV" code:1 + userInfo:@{NSLocalizedDescriptionKey: @"empty body"}]; + } + return nil; + } + + NSString *prefix = hrefPrefix ?: @""; + while ([prefix hasSuffix:@"/"]) prefix = [prefix substringToIndex:prefix.length - 1]; + + FPWebDAVParserDelegate *delegate = [[FPWebDAVParserDelegate alloc] init]; + delegate.hrefPrefix = prefix; + + NSXMLParser *parser = [[NSXMLParser alloc] initWithData:xml]; + parser.shouldProcessNamespaces = YES; + parser.delegate = delegate; + + if (![parser parse]) { + if (error) *error = parser.parserError; + return nil; + } + return delegate.entries; +} + ++ (void)propfindChildrenAtDavBase:(NSString *)davBase + relativePath:(NSString *)relativePath + token:(NSString *)token + completion:(void (^)(NSArray *_Nullable, + NSError *_Nullable))completion { + NSString *base = davBase; + while ([base hasSuffix:@"/"]) base = [base substringToIndex:base.length - 1]; + + // Build the request URL: base + "/" + percent-encoded relative path. + NSString *urlString = base; + if (relativePath.length > 0) { + NSString *encoded = [relativePath stringByAddingPercentEncodingWithAllowedCharacters: + [NSCharacterSet URLPathAllowedCharacterSet]]; + urlString = [NSString stringWithFormat:@"%@/%@", base, encoded]; + } + urlString = [urlString stringByAppendingString:@"/"]; // PROPFIND on a collection + + NSURL *url = [NSURL URLWithString:urlString]; + if (!url) { + completion(nil, [NSError errorWithDomain:@"FileProviderWebDAV" code:2 + userInfo:@{NSLocalizedDescriptionKey: @"bad URL"}]); + return; + } + + // hrefPrefix = the space-root path as it appears (decoded) in response hrefs. + NSString *hrefPrefix = [NSURL URLWithString:base].path ?: @""; + + NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:url]; + req.HTTPMethod = @"PROPFIND"; + [req setValue:[NSString stringWithFormat:@"Bearer %@", token] forHTTPHeaderField:@"Authorization"]; + [req setValue:@"1" forHTTPHeaderField:@"Depth"]; + [req setValue:@"application/xml; charset=utf-8" forHTTPHeaderField:@"Content-Type"]; + req.HTTPBody = [@"" + "" + "" + "" + "" dataUsingEncoding:NSUTF8StringEncoding]; + + // Reuse a single session so TCP/TLS connections are kept alive between + // PROPFINDs (a fresh session per request forces a full handshake every + // folder open — the main cause of the per-folder browse latency). + static NSURLSession *sharedSession = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSURLSessionConfiguration *cfg = [NSURLSessionConfiguration defaultSessionConfiguration]; + cfg.HTTPMaximumConnectionsPerHost = 6; + cfg.timeoutIntervalForRequest = 30; + sharedSession = [NSURLSession sessionWithConfiguration:cfg]; + }); + [[sharedSession dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *response, NSError *netErr) { + if (netErr) { + completion(nil, netErr); + return; + } + NSHTTPURLResponse *http = (NSHTTPURLResponse *)response; + if (http.statusCode < 200 || http.statusCode >= 300) { + NSError *e = [NSError errorWithDomain:@"FileProviderWebDAV" code:http.statusCode + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"PROPFIND HTTP %ld", (long)http.statusCode]}]; + completion(nil, e); + return; + } + NSError *parseErr = nil; + NSArray *entries = + [self parseMultistatus:data hrefPrefix:hrefPrefix error:&parseErr]; + completion(entries, parseErr); + }] resume]; +} + +@end diff --git a/src/extensions/fileprovider/FileProviderWorkingSetDelta.h b/src/extensions/fileprovider/FileProviderWorkingSetDelta.h new file mode 100644 index 0000000000..666a869963 --- /dev/null +++ b/src/extensions/fileprovider/FileProviderWorkingSetDelta.h @@ -0,0 +1,45 @@ +// FileProviderWorkingSetDelta -- pure diff of the working set against its +// previous snapshot. Foundation-only so it is unit-testable without the +// FileProvider framework. +// +// The working-set enumerator used to report EVERY item as "updated" on every +// change-enumeration pass, which made fileproviderd re-index the whole tree +// (hundreds of items) every ~30s — wasting CPU and constantly rewriting +// Finder's view. This helper computes the real delta so only genuinely new or +// changed items are reported. + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FPWorkingSetDelta : NSObject +/// Item dicts that are new or whose etag changed since the previous snapshot. +@property (nonatomic, strong) NSArray *changedItems; +/// File IDs that were in the previous snapshot but are gone now. +@property (nonatomic, strong) NSArray *deletedFileIds; +/// All current file IDs, for persisting as the next snapshot. +@property (nonatomic, strong) NSArray *currentFileIds; +@end + +/// Diffs `currentItems` against `previousItems` (each dict carries "fileId", +/// "etag" and "path"/"filename"). An item counts as changed when it is new OR its +/// fingerprint differs, where the fingerprint is etag+path — so a server-side +/// RENAME (same fileId and etag, different name) is reported as changed. The +/// previous etag-only comparison missed renames entirely. +FPWorkingSetDelta *FPComputeWorkingSetDelta(NSArray *currentItems, NSArray *previousItems); + +/// Identity fingerprint of an item: changes on content (etag) OR name/path change. +NSString *FPItemFingerprint(NSDictionary *item); + +/// Deterministic content signature of an item set, used as the FileProvider sync +/// anchor. It MUST change whenever any item is added, removed, renamed, moved or +/// its content changes — otherwise fileproviderd, which re-enumerates only when +/// the anchor changes, never picks the change up. The previous implementation used +/// `count + max(modtime)`, which is invariant under a rename (count and modtime are +/// both unchanged), so server-side renames never propagated to Finder. This hashes +/// the sorted `fileId|path|etag` of every item, so a rename (path change and/or new +/// fileId) always moves the anchor. Foundation-only and order-independent so it is +/// unit-testable and stable across process launches. +NSString *FPItemSetSignature(NSArray *items); + +NS_ASSUME_NONNULL_END diff --git a/src/extensions/fileprovider/FileProviderWorkingSetDelta.mm b/src/extensions/fileprovider/FileProviderWorkingSetDelta.mm new file mode 100644 index 0000000000..01db008bca --- /dev/null +++ b/src/extensions/fileprovider/FileProviderWorkingSetDelta.mm @@ -0,0 +1,86 @@ +// FileProviderWorkingSetDelta -- see header. + +#import "FileProviderWorkingSetDelta.h" + +@implementation FPWorkingSetDelta +@end + +NSString *FPItemFingerprint(NSDictionary *item) { + NSString *etag = item[@"etag"] ?: @""; + NSString *path = item[@"path"] ?: (item[@"filename"] ?: @""); + return [NSString stringWithFormat:@"%@|%@", etag, path]; +} + +FPWorkingSetDelta *FPComputeWorkingSetDelta( + NSArray *currentItems, + NSArray *previousItems) { + + // Build the previous fingerprint map and id set from the previous snapshot. + NSMutableDictionary *prevFingerprint = + [NSMutableDictionary dictionaryWithCapacity:previousItems.count]; + for (NSDictionary *dict in previousItems) { + NSString *fid = dict[@"fileId"] ?: @""; + if (fid.length) prevFingerprint[fid] = FPItemFingerprint(dict); + } + + NSMutableArray *changed = [NSMutableArray array]; + NSMutableArray *currentIds = [NSMutableArray arrayWithCapacity:currentItems.count]; + NSMutableSet *currentIdSet = [NSMutableSet setWithCapacity:currentItems.count]; + + for (NSDictionary *dict in currentItems) { + NSString *fid = dict[@"fileId"] ?: @""; + [currentIds addObject:fid]; + [currentIdSet addObject:fid]; + + NSString *prevFp = prevFingerprint[fid]; // nil => unknown/new + NSString *curFp = FPItemFingerprint(dict); + BOOL isNew = (prevFp == nil); + // Reported as changed when new, content (etag) changed, OR renamed/moved + // (path changed) — the latter is what the old etag-only check missed. + if (isNew || ![prevFp isEqualToString:curFp]) { + [changed addObject:dict]; + } + } + + // Deletions: ids known before that are no longer present. + NSMutableArray *deleted = [NSMutableArray array]; + for (NSString *fid in prevFingerprint) { + if (![currentIdSet containsObject:fid]) { + [deleted addObject:fid]; + } + } + + FPWorkingSetDelta *result = [[FPWorkingSetDelta alloc] init]; + result.changedItems = changed; + result.deletedFileIds = deleted; + result.currentFileIds = currentIds; + return result; +} + +NSString *FPItemSetSignature(NSArray *items) { + // Build one stable token per item from its identity-relevant fields, then + // sort so the signature is order-independent. fileId catches add/remove (and + // renames that yield a new id), path catches in-place renames/moves, etag + // catches content changes. + NSMutableArray *parts = [NSMutableArray arrayWithCapacity:items.count]; + for (NSDictionary *d in items) { + NSString *fid = d[@"fileId"] ?: @""; + NSString *path = d[@"path"] ?: (d[@"filename"] ?: @""); + NSString *etag = d[@"etag"] ?: @""; + [parts addObject:[NSString stringWithFormat:@"%@|%@|%@", fid, path, etag]]; + } + [parts sortUsingSelector:@selector(compare:)]; + + // FNV-1a 64-bit over the joined tokens. Deterministic across launches (unlike + // -[NSString hash]), so an unchanged tree yields a stable anchor and does not + // trigger spurious full re-enumeration after a relaunch. + uint64_t h = 1469598103934665603ULL; // FNV offset basis + NSString *joined = [parts componentsJoinedByString:@"\n"]; + const char *bytes = joined.UTF8String ?: ""; + for (const char *p = bytes; *p; ++p) { + h ^= (uint8_t)(*p); + h *= 1099511628211ULL; // FNV prime + } + return [NSString stringWithFormat:@"%lu-%016llx", + (unsigned long)items.count, (unsigned long long)h]; +} 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..cbee5b7ddd --- /dev/null +++ b/src/extensions/fileprovider/OpenCloudFileProviderExtension.mm @@ -0,0 +1,1524 @@ +// 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 "FileProviderItemCache.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"); +} + +/// Returns the per-domain config plist filename, falling back to the legacy +/// global filename if the per-domain file does not exist yet. +static NSString *configPlistName(NSFileProviderDomain *domain, NSURL *containerURL) { + NSString *perDomain = [NSString stringWithFormat:@"fileprovider_config_%@.plist", domain.identifier]; + if ([[NSFileManager defaultManager] fileExistsAtPath: + [[containerURL URLByAppendingPathComponent:perDomain] path]]) { + return perDomain; + } + return @"fileprovider_config.plist"; // legacy fallback +} + +/// Returns the per-domain items plist filename, falling back to the legacy +/// global filename if the per-domain file does not exist yet. +static NSString *itemsPlistName(NSFileProviderDomain *domain, NSURL *containerURL) { + NSString *perDomain = [NSString stringWithFormat:@"fileprovider_items_%@.plist", domain.identifier]; + if ([[NSFileManager defaultManager] fileExistsAtPath: + [[containerURL URLByAppendingPathComponent:perDomain] path]]) { + return perDomain; + } + return @"fileprovider_items.plist"; // legacy fallback +} + +#pragma mark - Default Item Capabilities + +/// Returns default NSFileProviderItemCapabilities for items served by this extension. +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; + + /// Shared per-domain metadata cache populated by the enumerator's live + /// PROPFIND results. Source of truth for itemForIdentifier and hydration paths. + FileProviderItemCache *_itemCache; +} + +#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_debug(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]; + + // Per-domain metadata cache backing server-driven enumeration. + NSURL *groupURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (groupURL) { + NSURL *cacheURL = [groupURL URLByAppendingPathComponent: + [NSString stringWithFormat:@"fileprovider_idcache_%@.plist", domain.identifier]]; + _itemCache = [[FileProviderItemCache alloc] initWithFileURL:cacheURL]; + } + + _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]; + } + + // Cache first: populated by the enumerator's live PROPFIND results. + if (_itemCache) { + NSDictionary *md = [_itemCache metadataForFileId:identifier]; + if (md) { + os_log_info(extensionLog(), "itemForIdentifier: found %{public}@ in cache", identifier); + completionHandler([[FileProviderItem alloc] initWithDictionary:md], nil); + return [NSProgress discreteProgressWithTotalUnitCount:0]; + } + } + + // Fallback: look up the item from the shared metadata plist in the App Group + // container (legacy path while the main app still writes it). + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (containerURL) { + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; + 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:configPlistName(self->_domain, containerURL)]; + 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"]; // fallback + NSString *accessToken = config[@"accessToken"]; + if (!accessToken || accessToken.length == 0) { + os_log_error(extensionLog(), "fetchContents: no access token — app may still be starting"); + [self _completeHydrationForFileId:fileId url:nil item:nil + error:configUnavailableError(@"Anmeldung wird vorbereitet — bitte kurz warten")]; + return; + } + + // Resolve the file path. Cache first (server-driven enumeration), then + // fall back to the legacy items plist. + NSString *filePath = nil; + NSDictionary *itemDict = nil; + if (self->_itemCache) { + NSDictionary *md = [self->_itemCache metadataForFileId:fileId]; + if (md[@"path"]) { filePath = md[@"path"]; itemDict = md; } + } + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; + NSData *metaData = filePath ? nil : [NSData dataWithContentsOfURL:metadataURL]; + 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; + } + + // Use per-item davUrl if available (correct space), fall back to global config. + if (itemDict[@"davUrl"]) { + davUrl = itemDict[@"davUrl"]; + } + if (!davUrl || davUrl.length == 0) { + os_log_error(extensionLog(), "fetchContents: no davUrl for %{public}@", fileId); + [self _completeHydrationForFileId:fileId url:nil item:nil + error:configUnavailableError(@"Server-URL nicht konfiguriert")]; + return; + } + + // Build the WebDAV download URL: davUrl + "/" + filePath + NSString *davBase = [davUrl hasSuffix:@"/"] ? [davUrl substringToIndex:davUrl.length - 1] : davUrl; + NSString *encodedPath = [filePath stringByAddingPercentEncodingWithAllowedCharacters: + [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) { + // Use a transient error so fileproviderd retries later + // instead of permanently removing the item from Finder. + // The item will be cleaned up by the next sync cycle if + // it was truly deleted on the server. + fpCode = NSFileProviderErrorServerUnreachable; + userMessage = NSLocalizedString( + @"Diese Datei wurde auf dem Server nicht gefunden. Sie wurde möglicherweise gelöscht oder verschoben.", + @"FileProvider HTTP 404"); + } 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:itemsPlistName(self->_domain, containerURL)]; + 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); + } + } +} + +/// Updates an existing item's path and parent in the plist after a MOVE. +/// Does NOT set extensionCreated, so the item behaves like a journal-sourced +/// entry and gets cleaned up normally when deleted on the server. +- (void)_updateItemPathInPlist:(NSString *)fileId + newPath:(NSString *)newPath + newParentId:(NSString *)newParentId + newParentPath:(NSString *)newParentPath + davUrl:(NSString *)davUrl { + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) return; + + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; + NSData *data = [NSData dataWithContentsOfURL:metadataURL]; + if (!data) return; + + NSArray *items = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListImmutable + format:nil error:nil]; + if (![items isKindOfClass:[NSArray class]]) return; + + NSMutableArray *mutableItems = [items mutableCopy]; + for (NSUInteger i = 0; i < mutableItems.count; i++) { + NSDictionary *item = mutableItems[i]; + if ([item[@"fileId"] isEqualToString:fileId]) { + NSMutableDictionary *updated = [item mutableCopy]; + updated[@"path"] = newPath; + updated[@"parentId"] = newParentId; + updated[@"parentPath"] = newParentPath; + if (davUrl) updated[@"davUrl"] = davUrl; + // Remove extensionCreated but add movedAt timestamp. + // The merge logic preserves moved items briefly so the + // enumerator can register them in prevFileIds. Without + // this, server-side deletions of moved files are never + // detected because the item was never in prevFileIds + // for the new parent container. + [updated removeObjectForKey:@"extensionCreated"]; + [updated removeObjectForKey:@"extensionCreatedAt"]; + updated[@"movedAt"] = @((int64_t)[[NSDate date] timeIntervalSince1970]); + [mutableItems replaceObjectAtIndex:i withObject:updated]; + break; + } + } + + NSData *newData = [NSPropertyListSerialization dataWithPropertyList:mutableItems + format:NSPropertyListBinaryFormat_v1_0 + options:0 error:nil]; + if (newData) { + [newData writeToURL:metadataURL atomically:YES]; + os_log_info(extensionLog(), "Updated item %{public}@ path to %{public}@ in plist", fileId, newPath); + } +} + +/// Appends a newly created item to the shared fileprovider_items.plist so +/// subsequent enumerations include it. Without this, items created via +/// createItemBasedOnTemplate would disappear from Finder on the next +/// enumeration cycle because the enumerator only reports plist contents. +- (void)_appendItemToPlist:(NSDictionary *)itemDict { + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) return; + + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; + NSMutableArray *items = nil; + NSData *data = [NSData dataWithContentsOfURL:metadataURL]; + if (data) { + NSArray *existing = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListImmutable + format:nil error:nil]; + items = existing ? [existing mutableCopy] : [NSMutableArray array]; + } else { + items = [NSMutableArray array]; + } + + // Mark the item as extension-created so syncMetadataToSharedContainer + // in the main app preserves it until the sync engine discovers it. + // Items without this flag are treated as journal-sourced and will be + // removed when they disappear from the journal (e.g. server-side delete). + NSMutableDictionary *markedItem = [itemDict mutableCopy]; + markedItem[@"extensionCreated"] = @YES; + markedItem[@"extensionCreatedAt"] = @((int64_t)[[NSDate date] timeIntervalSince1970]); + + // Replace any existing entry with the same fileId to avoid duplicates. + NSString *newFileId = markedItem[@"fileId"]; + NSUInteger existingIndex = NSNotFound; + for (NSUInteger i = 0; i < items.count; i++) { + if ([items[i][@"fileId"] isEqualToString:newFileId]) { + existingIndex = i; + break; + } + } + if (existingIndex != NSNotFound) { + [items replaceObjectAtIndex:existingIndex withObject:markedItem]; + } else { + [items addObject:markedItem]; + } + + NSData *newData = [NSPropertyListSerialization dataWithPropertyList:items + format:NSPropertyListBinaryFormat_v1_0 + options:0 error:nil]; + if (newData) { + [newData writeToURL:metadataURL atomically:YES]; + os_log_info(extensionLog(), "Appended item %{public}@ to shared plist (total: %lu)", + newFileId, (unsigned long)items.count); + } +} + +#pragma mark - NSFileProviderReplicatedExtension (Create) + +- (NSProgress *)createItemBasedOnTemplate:(id)itemTemplate + 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 url=%@\n", + [NSDate date], templateName, templateParent, (unsigned long)options, url.path ?: @"(nil)"]); + + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (containerURL) { + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; + 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_info(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 access token from global config. + NSURL *configURL = [containerURL URLByAppendingPathComponent:configPlistName(self->_domain, containerURL)]; + NSData *configData = [NSData dataWithContentsOfURL:configURL]; + NSDictionary *config = configData + ? [NSPropertyListSerialization propertyListWithData:configData options:NSPropertyListImmutable format:nil error:nil] + : nil; + NSString *accessToken = config[@"accessToken"]; + + if (!accessToken || accessToken.length == 0) { + completionHandler(nil, 0, NO, configUnavailableError(@"Anmeldung fehlt")); + return progress; + } + + // Resolve parent path and davUrl from items plist. + // Each item carries its own davUrl so we use the correct space. + NSString *parentPath = @""; + NSString *davUrl = config[@"davUrl"]; // fallback to global config + NSURL *metaURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; + NSData *metaData = [NSData dataWithContentsOfURL:metaURL]; + if (metaData) { + NSArray *items = [NSPropertyListSerialization propertyListWithData:metaData + options:NSPropertyListImmutable format:nil error:nil]; + for (NSDictionary *item in items) { + if (![parentId isEqualToString:NSFileProviderRootContainerItemIdentifier] + && [item[@"fileId"] isEqualToString:parentId]) { + parentPath = item[@"path"] ?: @""; + if (item[@"davUrl"]) davUrl = item[@"davUrl"]; + break; + } + // For root-level items, grab davUrl from any item in the plist. + if ([parentId isEqualToString:NSFileProviderRootContainerItemIdentifier] + && item[@"davUrl"] && !davUrl) { + davUrl = item[@"davUrl"]; + } + } + } + + if (!davUrl || davUrl.length == 0) { + completionHandler(nil, 0, NO, configUnavailableError(@"Server-URL nicht konfiguriert")); + return progress; + } + + NSString *filename = itemTemplate.filename; + NSString *davBase = [davUrl hasSuffix:@"/"] ? [davUrl substringToIndex:davUrl.length - 1] : davUrl; + + 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 *dirPath = parentPath.length > 0 + ? [NSString stringWithFormat:@"%@/%@", parentPath, filename] + : filename; + + // Block that finalises the created-item and calls completionHandler. + // Extracted so it can be called from both the PROPFIND path and the + // direct fallback path below. + void (^finish)(NSString *, NSString *, int64_t) = + ^(NSString *canonicalFileId, NSString *etag, int64_t modtime) { + NSDictionary *dirDict = @{ + @"fileId": canonicalFileId, + @"filename": filename, + @"path": dirPath, + @"parentId": parentId, + @"parentPath": parentPath, + @"isDirectory": @YES, + @"size": @0, + @"modtime": @(modtime), + @"etag": etag ?: @"", + @"isVirtualFile": @NO, + @"isDownloaded": @YES, + @"davUrl": davUrl, + }; + FileProviderItem *createdItem = [[FileProviderItem alloc] initWithDictionary:dirDict]; + os_log_info(extensionLog(), "createItem: directory done id=%{public}@", canonicalFileId); + [self _appendItemToPlist:dirDict]; + progress.completedUnitCount = 100; + completionHandler(createdItem, NSFileProviderItemFields(0), NO, nil); + }; + + // Do a PROPFIND (Depth: 0) on the newly created folder to retrieve + // the canonical fileId (oc:id) that the sync engine will later store + // in its journal. Using the MKCOL OC-FileId header alone is not + // reliable — the header may use a different format (e.g. bare numeric) + // than the journal (e.g. "spaceId!numeric"). A mismatch causes + // NSFileProvider to see two items with the same name → "abc 2" duplicate. + NSMutableURLRequest *pfReq = [NSMutableURLRequest requestWithURL:mkcolURL]; + pfReq.HTTPMethod = @"PROPFIND"; + [pfReq setValue:[NSString stringWithFormat:@"Bearer %@", accessToken] + forHTTPHeaderField:@"Authorization"]; + [pfReq setValue:@"0" forHTTPHeaderField:@"Depth"]; + [pfReq setValue:@"application/xml; charset=utf-8" forHTTPHeaderField:@"Content-Type"]; + pfReq.HTTPBody = [@"" + "" + "" + "" dataUsingEncoding:NSUTF8StringEncoding]; + + NSURLSession *pfSession = [NSURLSession sessionWithConfiguration: + [NSURLSessionConfiguration defaultSessionConfiguration]]; + [[pfSession dataTaskWithRequest:pfReq + completionHandler:^(NSData *pfData, NSURLResponse *pfResp, NSError *pfErr) { + NSString *canonicalId = nil; + NSString *etag = nil; + int64_t modtime = (int64_t)[[NSDate date] timeIntervalSince1970]; + + if (!pfErr && pfData) { + NSString *xml = [[NSString alloc] initWithData:pfData encoding:NSUTF8StringEncoding]; + // Extract oc:id — the canonical fileId the sync journal uses. + NSRegularExpression *idRe = [NSRegularExpression + regularExpressionWithPattern:@"<[^:>]+:id[^>]*>([^<]+)]+:id>" + options:0 error:nil]; + NSTextCheckingResult *m = [idRe firstMatchInString:xml options:0 + range:NSMakeRange(0, xml.length)]; + if (m && m.numberOfRanges > 1) { + canonicalId = [xml substringWithRange:[m rangeAtIndex:1]]; + } + // Extract etag. + NSRegularExpression *etagRe = [NSRegularExpression + regularExpressionWithPattern:@"<[^:>]+:getetag[^>]*>\"?([^<\"]+)\"?]+:getetag>" + options:0 error:nil]; + NSTextCheckingResult *em = [etagRe firstMatchInString:xml options:0 + range:NSMakeRange(0, xml.length)]; + if (em && em.numberOfRanges > 1) { + etag = [xml substringWithRange:[em rangeAtIndex:1]]; + } + } + + // Fall back to OC-FileId header if PROPFIND failed or returned no id. + if (!canonicalId || canonicalId.length == 0) { + canonicalId = [http.allHeaderFields[@"OC-FileId"] copy] + ?: [NSString stringWithFormat:@"ext!%@", [[NSUUID UUID] UUIDString]]; + os_log_info(extensionLog(), + "createItem: PROPFIND id not found, using fallback: %{public}@", canonicalId); + appendTrace([NSString stringWithFormat: + @"[%@] createItem PROPFIND-FALLBACK name=%@ id=%@ pfErr=%@ pfStatus=%ld\n", + [NSDate date], filename, canonicalId, pfErr.localizedDescription ?: @"nil", + (long)((NSHTTPURLResponse *)pfResp).statusCode]); + } else { + os_log_info(extensionLog(), + "createItem: PROPFIND canonical id=%{public}@", canonicalId); + appendTrace([NSString stringWithFormat: + @"[%@] createItem PROPFIND-OK name=%@ id=%@\n", + [NSDate date], filename, canonicalId]); + } + + finish(canonicalId, etag, modtime); + }] resume]; + }] resume]; + + } else { + // --- File upload via PUT --- + + // Stage the content to a temporary file before the async upload. + // The system-provided content URL may become invalid after this + // method returns, so we must copy it synchronously. + NSString *stagingDir = NSTemporaryDirectory(); + NSString *stagingFilename = [NSString stringWithFormat:@"upload-%@-%@", + filename, [[NSUUID UUID] UUIDString]]; + NSURL *stagingURL = [NSURL fileURLWithPath:[stagingDir stringByAppendingPathComponent:stagingFilename]]; + + NSError *copyError = nil; + [[NSFileManager defaultManager] copyItemAtURL:url toURL:stagingURL error:©Error]; + if (copyError) { + os_log_error(extensionLog(), "createItem: failed to stage content: %{public}@", + copyError.localizedDescription); + completionHandler(nil, 0, NO, copyError); + return progress; + } + + NSString *filePath = parentPath.length > 0 + ? [NSString stringWithFormat:@"%@/%@", parentPath, filename] + : filename; + 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]]; + // Get file size before the async upload (staging file is deleted in the handler). + int64_t stagedFileSize = 0; + { + NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:stagingURL.path error:nil]; + if (attrs) stagedFileSize = [attrs[NSFileSize] longLongValue]; + } + + [[session uploadTaskWithRequest:req fromFile:stagingURL completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + // Clean up staging file. + [[NSFileManager defaultManager] removeItemAtURL:stagingURL error:nil]; + + if (error) { + os_log_error(extensionLog(), "createItem: PUT failed: %{public}@", error.localizedDescription); + completionHandler(nil, 0, NO, error); + 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 *putEtag = [http.allHeaderFields[@"ETag"] copy] ?: @""; + int64_t fileSize = stagedFileSize; + + // Helper block to finish item creation with a confirmed fileId. + void (^finishFile)(NSString *, NSString *, int64_t) = + ^(NSString *fileId, NSString *etag, int64_t modtime) { + NSMutableDictionary *itemDict = [@{ + @"fileId": fileId, + @"filename": filename, + @"path": filePath, + @"parentId": parentId, + @"parentPath": parentPath, + @"isDirectory": @NO, + @"size": @(fileSize), + @"modtime": @(modtime), + @"etag": etag ?: @"", + @"isVirtualFile": @NO, + @"isDownloaded": @YES, + @"davUrl": davUrl, + } mutableCopy]; + FileProviderItem *createdItem = [[FileProviderItem alloc] initWithDictionary:itemDict]; + os_log_info(extensionLog(), "createItem: uploaded %{public}@ id=%{public}@", filename, fileId); + // Persist the new item in the shared plist so subsequent + // enumerations include it. Without this, the enumerator + // would not report the item, and fileproviderd would + // eventually remove it from Finder. + [self _appendItemToPlist:itemDict]; + progress.completedUnitCount = 100; + completionHandler(createdItem, NSFileProviderItemFields(0), NO, nil); + }; + + // PROPFIND (Depth: 0) to get the canonical oc:id the sync journal will store. + // PUT's OC-FileId header may use a different format (bare numeric) than + // the journal (spaceId!UUID). A mismatch causes NSFileProvider to see two + // items with the same name → file "reappears" in the source folder. + NSMutableURLRequest *pfReq = [NSMutableURLRequest requestWithURL:putURL]; + pfReq.HTTPMethod = @"PROPFIND"; + [pfReq setValue:[NSString stringWithFormat:@"Bearer %@", accessToken] + forHTTPHeaderField:@"Authorization"]; + [pfReq setValue:@"0" forHTTPHeaderField:@"Depth"]; + [pfReq setValue:@"application/xml; charset=utf-8" forHTTPHeaderField:@"Content-Type"]; + pfReq.HTTPBody = [@"" + "" + "" + "" dataUsingEncoding:NSUTF8StringEncoding]; + + NSURLSession *pfSession = [NSURLSession sessionWithConfiguration: + [NSURLSessionConfiguration defaultSessionConfiguration]]; + [[pfSession dataTaskWithRequest:pfReq + completionHandler:^(NSData *pfData, NSURLResponse *pfResp, NSError *pfErr) { + NSString *canonicalId = nil; + NSString *etag = nil; + int64_t modtime = (int64_t)[[NSDate date] timeIntervalSince1970]; + + if (!pfErr && pfData) { + NSString *xml = [[NSString alloc] initWithData:pfData encoding:NSUTF8StringEncoding]; + NSRegularExpression *idRe = [NSRegularExpression + regularExpressionWithPattern:@"<[^:>]+:id[^>]*>([^<]+)]+:id>" + options:0 error:nil]; + NSTextCheckingResult *m = [idRe firstMatchInString:xml options:0 + range:NSMakeRange(0, xml.length)]; + if (m && m.numberOfRanges > 1) { + canonicalId = [xml substringWithRange:[m rangeAtIndex:1]]; + } + NSRegularExpression *etagRe = [NSRegularExpression + regularExpressionWithPattern:@"<[^:>]+:getetag[^>]*>\"?([^<\"]+)\"?]+:getetag>" + options:0 error:nil]; + NSTextCheckingResult *em = [etagRe firstMatchInString:xml options:0 + range:NSMakeRange(0, xml.length)]; + if (em && em.numberOfRanges > 1) { + etag = [xml substringWithRange:[em rangeAtIndex:1]]; + } + } + + if (!canonicalId || canonicalId.length == 0) { + canonicalId = [http.allHeaderFields[@"OC-FileId"] copy] + ?: [NSString stringWithFormat:@"ext!%@", [[NSUUID UUID] UUIDString]]; + os_log_info(extensionLog(), + "createItem: PUT PROPFIND id not found, using fallback: %{public}@", canonicalId); + appendTrace([NSString stringWithFormat: + @"[%@] createItem PUT-PROPFIND-FALLBACK name=%@ id=%@ pfErr=%@ pfStatus=%ld\n", + [NSDate date], filename, canonicalId, + pfErr.localizedDescription ?: @"nil", + (long)((NSHTTPURLResponse *)pfResp).statusCode]); + } else { + os_log_info(extensionLog(), + "createItem: PUT PROPFIND canonical id=%{public}@", canonicalId); + appendTrace([NSString stringWithFormat: + @"[%@] createItem PUT-PROPFIND-OK name=%@ id=%@\n", + [NSDate date], filename, canonicalId]); + } + + finishFile(canonicalId, etag ?: putEtag, modtime); + }] resume]; + }] 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); + appendTrace([NSString stringWithFormat:@"[%@] modifyItem: name=%@ id=%@ changedFields=0x%lx contents=%@\n", + [NSDate date], item.filename, item.itemIdentifier, + (unsigned long)changedFields, newContents.path ?: @"(nil)"]); + + NSProgress *progress = [NSProgress discreteProgressWithTotalUnitCount:100]; + + NSString *fileId = [item.itemIdentifier copy]; + + // Handle re-parent (move) via direct WebDAV MOVE — no XPC needed. + // This must be checked BEFORE the XPC proxy check below, otherwise + // moves fail when XPC is unavailable and Finder creates a copy instead. + if (changedFields & NSFileProviderItemParentItemIdentifier) { + NSString *newParentId = [item.parentItemIdentifier copy]; + os_log_info(extensionLog(), "modifyItem: moving %{public}@ to parent %{public}@", fileId, newParentId); + + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) { + completionHandler(nil, 0, NO, configUnavailableError(@"App-Container nicht verfügbar")); + return progress; + } + + NSURL *cfgURL = [containerURL URLByAppendingPathComponent:configPlistName(self->_domain, containerURL)]; + NSData *cfgData = [NSData dataWithContentsOfURL:cfgURL]; + NSDictionary *cfg = cfgData ? [NSPropertyListSerialization propertyListWithData:cfgData + options:NSPropertyListImmutable format:nil error:nil] : nil; + NSString *movAccessToken = cfg[@"accessToken"]; + NSString *movDavUrl = cfg[@"davUrl"]; + + // Look up source path and per-item davUrl. + NSString *srcPath = nil; + NSString *newParentPath = @""; + NSURL *metURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; + NSData *metData = [NSData dataWithContentsOfURL:metURL]; + if (metData) { + NSArray *allItems = [NSPropertyListSerialization propertyListWithData:metData + options:NSPropertyListImmutable format:nil error:nil]; + for (NSDictionary *it in allItems) { + if ([it[@"fileId"] isEqualToString:fileId]) { + srcPath = it[@"path"]; + if (it[@"davUrl"]) movDavUrl = it[@"davUrl"]; + } + if ([it[@"fileId"] isEqualToString:newParentId]) { + newParentPath = it[@"path"] ?: @""; + } + } + } + + if (!srcPath || !movDavUrl || !movAccessToken) { + completionHandler(nil, 0, NO, configUnavailableError(@"Verschieben nicht möglich — Konfiguration fehlt")); + return progress; + } + + NSString *filename = item.filename; + NSString *destPath = newParentPath.length > 0 + ? [NSString stringWithFormat:@"%@/%@", newParentPath, filename] : filename; + NSString *movDavBase = [movDavUrl hasSuffix:@"/"] ? [movDavUrl substringToIndex:movDavUrl.length - 1] : movDavUrl; + + NSString *srcEncoded = [srcPath stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLPathAllowedCharacterSet]]; + NSString *destEncoded = [destPath stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLPathAllowedCharacterSet]]; + NSString *srcURLString = [NSString stringWithFormat:@"%@/%@", movDavBase, srcEncoded]; + NSString *destURLString = [NSString stringWithFormat:@"%@/%@", movDavBase, destEncoded]; + + os_log_info(extensionLog(), "modifyItem: MOVE %{public}@ -> %{public}@", srcURLString, destURLString); + + NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:srcURLString]]; + req.HTTPMethod = @"MOVE"; + [req setValue:[NSString stringWithFormat:@"Bearer %@", movAccessToken] forHTTPHeaderField:@"Authorization"]; + [req setValue:destURLString forHTTPHeaderField:@"Destination"]; + [req setValue:@"F" forHTTPHeaderField:@"Overwrite"]; + + NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]]; + [[session dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + if (error) { + os_log_error(extensionLog(), "modifyItem: MOVE failed: %{public}@", error.localizedDescription); + completionHandler(nil, 0, NO, error); + return; + } + NSHTTPURLResponse *http = (NSHTTPURLResponse *)response; + if (http.statusCode < 200 || http.statusCode >= 300) { + os_log_error(extensionLog(), "modifyItem: MOVE HTTP %ld", (long)http.statusCode); + completionHandler(nil, 0, NO, [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Verschieben fehlgeschlagen (HTTP %ld)", (long)http.statusCode]}]); + return; + } + + // Update the plist entry in-place: change path/parent but do NOT + // mark as extensionCreated. The file was already in the journal — + // the sync engine will discover it at the new location. Without + // extensionCreated, server-side deletions propagate correctly. + [self _updateItemPathInPlist:fileId + newPath:destPath + newParentId:newParentId + newParentPath:newParentPath + davUrl:movDavUrl]; + + FileProviderItem *movedItem = [[FileProviderItem alloc] + initWithIdentifier:fileId filename:filename parentIdentifier:newParentId + isDirectory:NO size:[item.documentSize longLongValue] modDate:item.contentModificationDate]; + os_log_info(extensionLog(), "modifyItem: MOVE succeeded for %{public}@", fileId); + progress.completedUnitCount = 100; + completionHandler(movedItem, NSFileProviderItemFields(0), NO, nil); + }] resume]; + return progress; + } + + // Handle rename via direct WebDAV MOVE (same parent, new filename) — no XPC needed. + // NsfpXpcDelegate does not implement renameItem:newName:completionHandler:, so + // going through XPC would silently drop the operation and leave the server copy + // with the original name ("Neuer Ordner"), causing the sync engine to re-create + // the old name in Finder and producing a duplicate folder. + if (changedFields & NSFileProviderItemFilename) { + NSString *newName = [item.filename copy]; + os_log_info(extensionLog(), "modifyItem: renaming %{public}@ to '%{public}@'", fileId, newName); + + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) { + completionHandler(nil, 0, NO, configUnavailableError(@"App-Container nicht verfügbar")); + return progress; + } + + NSURL *cfgURL = [containerURL URLByAppendingPathComponent:configPlistName(self->_domain, containerURL)]; + NSData *cfgData = [NSData dataWithContentsOfURL:cfgURL]; + NSDictionary *cfg = cfgData + ? [NSPropertyListSerialization propertyListWithData:cfgData options:NSPropertyListImmutable format:nil error:nil] + : nil; + NSString *renAccessToken = cfg[@"accessToken"]; + NSString *renDavUrl = cfg[@"davUrl"]; + + // Look up current path, parent path, isDirectory, and per-item davUrl from plist. + NSString *srcPath = nil; + NSString *parentPath = @""; + NSString *currentParentId = [item.parentItemIdentifier copy]; + BOOL isDirectory = NO; + NSURL *metURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; + NSData *metData = [NSData dataWithContentsOfURL:metURL]; + if (metData) { + NSArray *allItems = [NSPropertyListSerialization propertyListWithData:metData + options:NSPropertyListImmutable format:nil error:nil]; + for (NSDictionary *it in allItems) { + if ([it[@"fileId"] isEqualToString:fileId]) { + srcPath = it[@"path"]; + parentPath = it[@"parentPath"] ?: @""; + isDirectory = [it[@"isDirectory"] boolValue]; + if (it[@"davUrl"]) renDavUrl = it[@"davUrl"]; + break; + } + } + } + + if (!srcPath || !renDavUrl || !renAccessToken) { + completionHandler(nil, 0, NO, configUnavailableError(@"Umbenennen nicht möglich — Konfiguration fehlt")); + return progress; + } + + // Destination: same parent directory, new filename. + NSString *destPath = parentPath.length > 0 + ? [NSString stringWithFormat:@"%@/%@", parentPath, newName] : newName; + NSString *renDavBase = [renDavUrl hasSuffix:@"/"] + ? [renDavUrl substringToIndex:renDavUrl.length - 1] : renDavUrl; + NSString *srcEncoded = [srcPath stringByAddingPercentEncodingWithAllowedCharacters: + [NSCharacterSet URLPathAllowedCharacterSet]]; + NSString *destEncoded = [destPath stringByAddingPercentEncodingWithAllowedCharacters: + [NSCharacterSet URLPathAllowedCharacterSet]]; + NSString *srcURLString = [NSString stringWithFormat:@"%@/%@", renDavBase, srcEncoded]; + NSString *destURLString = [NSString stringWithFormat:@"%@/%@", renDavBase, destEncoded]; + + os_log_info(extensionLog(), "modifyItem: MOVE (rename) %{public}@ -> %{public}@", + srcURLString, destURLString); + + NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:srcURLString]]; + req.HTTPMethod = @"MOVE"; + [req setValue:[NSString stringWithFormat:@"Bearer %@", renAccessToken] forHTTPHeaderField:@"Authorization"]; + [req setValue:destURLString forHTTPHeaderField:@"Destination"]; + [req setValue:@"F" forHTTPHeaderField:@"Overwrite"]; + + NSURLSession *session = [NSURLSession sessionWithConfiguration: + [NSURLSessionConfiguration defaultSessionConfiguration]]; + BOOL capturedIsDirectory = isDirectory; + [[session dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + if (error) { + os_log_error(extensionLog(), "modifyItem: rename MOVE failed: %{public}@", + error.localizedDescription); + completionHandler(nil, 0, NO, error); + return; + } + NSHTTPURLResponse *http = (NSHTTPURLResponse *)response; + if (http.statusCode < 200 || http.statusCode >= 300) { + os_log_error(extensionLog(), "modifyItem: rename MOVE HTTP %ld", (long)http.statusCode); + completionHandler(nil, 0, NO, [NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Umbenennen fehlgeschlagen (HTTP %ld)", + (long)http.statusCode]}]); + return; + } + + // Update the plist entry with the new name/path so the enumerator + // immediately reflects the rename and the sync engine does not + // re-create the old name as a duplicate in Finder. + [self _updateItemPathInPlist:fileId + newPath:destPath + newParentId:currentParentId + newParentPath:parentPath + davUrl:renDavUrl]; + + FileProviderItem *renamedItem = [[FileProviderItem alloc] + initWithIdentifier:fileId + filename:newName + parentIdentifier:currentParentId + isDirectory:capturedIsDirectory + size:[item.documentSize longLongValue] + modDate:item.contentModificationDate]; + os_log_info(extensionLog(), "modifyItem: rename succeeded for %{public}@ -> '%{public}@'", + fileId, newName); + progress.completedUnitCount = 100; + completionHandler(renamedItem, NSFileProviderItemFields(0), NO, nil); + }] resume]; + return progress; + } + + // Handle content update (re-upload via XPC). + id proxy = _xpcService.remoteObjectProxy; + if (changedFields & NSFileProviderItemContents) { + if (!proxy) { + os_log_error(extensionLog(), "modifyItem: no XPC proxy for content update of %{public}@", fileId); + completionHandler(nil, 0, NO, xpcUnavailableError()); + return progress; + } + if (!newContents) { + os_log_error(extensionLog(), "modifyItem: content change flagged but no content URL for %{public}@", + fileId); + 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]; + NSString *fileId = [identifier copy]; + + // --- Direct WebDAV DELETE (no XPC needed) --- + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) { + completionHandler(configUnavailableError(@"App-Container nicht verfügbar")); + return progress; + } + + // Read access token from global config. + NSURL *configURL = [containerURL URLByAppendingPathComponent:configPlistName(self->_domain, containerURL)]; + NSData *configData = [NSData dataWithContentsOfURL:configURL]; + NSDictionary *config = configData + ? [NSPropertyListSerialization propertyListWithData:configData options:NSPropertyListImmutable format:nil error:nil] + : nil; + NSString *davUrl = config[@"davUrl"]; // fallback + NSString *accessToken = config[@"accessToken"]; + + if (!accessToken || accessToken.length == 0) { + completionHandler(configUnavailableError(@"Anmeldung fehlt")); + return progress; + } + + // Look up file path and per-item davUrl from items plist. + NSString *filePath = nil; + NSURL *metaURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)]; + NSData *metaData = [NSData dataWithContentsOfURL:metaURL]; + if (metaData) { + NSArray *items = [NSPropertyListSerialization propertyListWithData:metaData + options:NSPropertyListImmutable format:nil error:nil]; + for (NSDictionary *item in items) { + if ([item[@"fileId"] isEqualToString:fileId]) { + filePath = item[@"path"]; + if (item[@"davUrl"]) davUrl = item[@"davUrl"]; + break; + } + } + } + + if (!davUrl || davUrl.length == 0) { + completionHandler(configUnavailableError(@"Server-URL nicht konfiguriert")); + return progress; + } + + if (!filePath || filePath.length == 0) { + os_log_error(extensionLog(), "deleteItem: fileId %{public}@ not found in items plist", fileId); + completionHandler([NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorNoSuchItem + userInfo:@{NSLocalizedDescriptionKey: @"Item not found in metadata"}]); + return progress; + } + + NSString *davBase = [davUrl hasSuffix:@"/"] ? [davUrl substringToIndex:davUrl.length - 1] : davUrl; + NSString *encodedPath = [filePath stringByAddingPercentEncodingWithAllowedCharacters: + [NSCharacterSet URLPathAllowedCharacterSet]]; + NSString *deleteURLString = [NSString stringWithFormat:@"%@/%@", davBase, encodedPath]; + NSURL *deleteURL = [NSURL URLWithString:deleteURLString]; + + os_log_info(extensionLog(), "deleteItem: DELETE %{public}@", deleteURLString); + + NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:deleteURL]; + req.HTTPMethod = @"DELETE"; + [req setValue:[NSString stringWithFormat:@"Bearer %@", accessToken] forHTTPHeaderField:@"Authorization"]; + + NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]]; + [[session dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + if (error) { + os_log_error(extensionLog(), "deleteItem: DELETE failed: %{public}@", error.localizedDescription); + completionHandler(error); + return; + } + NSHTTPURLResponse *http = (NSHTTPURLResponse *)response; + if (http.statusCode >= 200 && http.statusCode < 300) { + os_log_info(extensionLog(), "deleteItem: DELETE succeeded for %{public}@ (HTTP %ld)", + fileId, (long)http.statusCode); + // Remove the item from the shared plist so the enumerator + // no longer returns it. + [self _removeStaleItemFromPlist:fileId]; + progress.completedUnitCount = 1; + completionHandler(nil); + } else { + os_log_error(extensionLog(), "deleteItem: DELETE HTTP %ld for %{public}@", + (long)http.statusCode, fileId); + completionHandler([NSError errorWithDomain:NSFileProviderErrorDomain + code:NSFileProviderErrorServerUnreachable + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Löschen fehlgeschlagen (HTTP %ld)", (long)http.statusCode]}]); + } + }] resume]; + + return progress; +} + +#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 + domain:_domain + cache:_itemCache]; + 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/extensions/fileprovider/tests/run.sh b/src/extensions/fileprovider/tests/run.sh new file mode 100755 index 0000000000..8c3e5b3c0e --- /dev/null +++ b/src/extensions/fileprovider/tests/run.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Standalone unit tests for the File Provider extension's pure helpers. +# These compile without the full app build (Foundation only). +# +# ./run.sh +# +set -euo pipefail +cd "$(dirname "$0")" + +CXX="clang++ -fobjc-arc -framework Foundation -I.." +fail=0 + +build_run() { + local name="$1"; shift + local bin="/tmp/fp_test_${name}" + echo "=== ${name} ===" + # shellcheck disable=SC2086 + $CXX "$@" -o "$bin" + if "$bin"; then :; else fail=1; fi + echo +} + +build_run webdav test_fileprovider_webdav.mm ../FileProviderWebDAV.mm +build_run item_cache test_fileprovider_item_cache.mm ../FileProviderItemCache.mm +build_run ws_delta test_fileprovider_workingset_delta.mm ../FileProviderWorkingSetDelta.mm + +if [ "$fail" -eq 0 ]; then + echo "ALL TESTS PASSED" +else + echo "SOME TESTS FAILED" + exit 1 +fi \ No newline at end of file diff --git a/src/extensions/fileprovider/tests/test_fileprovider_item_cache.mm b/src/extensions/fileprovider/tests/test_fileprovider_item_cache.mm new file mode 100644 index 0000000000..5816a232e6 --- /dev/null +++ b/src/extensions/fileprovider/tests/test_fileprovider_item_cache.mm @@ -0,0 +1,82 @@ +// Standalone unit test for FileProviderItemCache. +// +// Build & run: +// clang++ -fobjc-arc -framework Foundation -I.. \ +// test_fileprovider_item_cache.mm ../FileProviderItemCache.mm \ +// -o /tmp/test_fp_cache && /tmp/test_fp_cache +// +#import +#import "FileProviderItemCache.h" + +static int g_failures = 0; +static int g_checks = 0; + +#define CHECK(cond, msg) do { \ + g_checks++; \ + if (!(cond)) { g_failures++; fprintf(stderr, "FAIL: %s (%s:%d)\n", msg, __FILE__, __LINE__); } \ +} while (0) + +static NSURL *tempCacheURL(void) { + NSString *name = [NSString stringWithFormat:@"fpcache-test-%@.plist", [[NSUUID UUID] UUIDString]]; + return [NSURL fileURLWithPath:[NSTemporaryDirectory() stringByAppendingPathComponent:name]]; +} + +int main(void) { + @autoreleasepool { + NSURL *url = tempCacheURL(); + + // Populate and persist. + FileProviderItemCache *c1 = [[FileProviderItemCache alloc] initWithFileURL:url]; + [c1 setPath:@"Arbeitsverträge" forFileId:@"id1"]; + [c1 setPath:@"Arbeitsverträge/2024" forFileId:@"id2"]; + [c1 setContainerPath:@"" etag:@"rootetag" childFileIds:@[@"id1"]]; + [c1 setContainerPath:@"Arbeitsverträge" etag:@"folderetag" childFileIds:@[@"id2"]]; + CHECK([c1 save], "save returned NO"); + + // In-memory lookups on the same instance. + CHECK([[c1 pathForFileId:@"id1"] isEqualToString:@"Arbeitsverträge"], "id1 path wrong (live)"); + CHECK([c1 pathForFileId:@"missing"] == nil, "missing id should be nil"); + + // Reload from disk into a fresh instance — persistence round-trip. + FileProviderItemCache *c2 = [[FileProviderItemCache alloc] initWithFileURL:url]; + CHECK([[c2 pathForFileId:@"id1"] isEqualToString:@"Arbeitsverträge"], "id1 path wrong (reloaded)"); + CHECK([[c2 pathForFileId:@"id2"] isEqualToString:@"Arbeitsverträge/2024"], "id2 path wrong (reloaded)"); + CHECK([[c2 etagForContainerPath:@""] isEqualToString:@"rootetag"], "root etag wrong (reloaded)"); + CHECK([[c2 etagForContainerPath:@"Arbeitsverträge"] isEqualToString:@"folderetag"], "folder etag wrong"); + + NSArray *rootKids = [c2 childFileIdsForContainerPath:@""]; + CHECK(rootKids.count == 1 && [rootKids[0] isEqualToString:@"id1"], "root children wrong"); + + // Updating a container replaces its snapshot. + [c2 setContainerPath:@"" etag:@"rootetag2" childFileIds:@[@"id1", @"idNew"]]; + CHECK([[c2 etagForContainerPath:@""] isEqualToString:@"rootetag2"], "etag not updated"); + CHECK([c2 childFileIdsForContainerPath:@""].count == 2, "children not updated"); + + // Unknown container -> nil. + CHECK([c2 etagForContainerPath:@"Nope"] == nil, "unknown container etag should be nil"); + + // Per-item metadata round-trips and is queryable by id, surviving reload. + [c2 setMetadata:@{ @"fileId": @"idM", @"filename": @"Report.pdf", + @"path": @"Privat/Report.pdf", @"isDirectory": @NO, + @"size": @4242, @"etag": @"mEtag" } + forFileId:@"idM"]; + CHECK([c2 save], "save (metadata) returned NO"); + FileProviderItemCache *c3 = [[FileProviderItemCache alloc] initWithFileURL:url]; + NSDictionary *md = [c3 metadataForFileId:@"idM"]; + CHECK(md != nil, "metadata missing after reload"); + CHECK([md[@"filename"] isEqualToString:@"Report.pdf"], "metadata filename wrong"); + CHECK([md[@"size"] longLongValue] == 4242, "metadata size wrong"); + // setMetadata also registers the id->path mapping. + CHECK([[c3 pathForFileId:@"idM"] isEqualToString:@"Privat/Report.pdf"], "metadata did not set path"); + CHECK([c3 metadataForFileId:@"unknown"] == nil, "unknown metadata should be nil"); + + [[NSFileManager defaultManager] removeItemAtURL:url error:nil]; + + if (g_failures == 0) { + printf("OK: %d checks passed\n", g_checks); + return 0; + } + fprintf(stderr, "%d/%d checks FAILED\n", g_failures, g_checks); + return 1; + } +} \ No newline at end of file diff --git a/src/extensions/fileprovider/tests/test_fileprovider_webdav.mm b/src/extensions/fileprovider/tests/test_fileprovider_webdav.mm new file mode 100644 index 0000000000..fd8797cd72 --- /dev/null +++ b/src/extensions/fileprovider/tests/test_fileprovider_webdav.mm @@ -0,0 +1,130 @@ +// Standalone unit test for FileProviderWebDAV (PROPFIND parser). +// +// Build & run (no full app build needed): +// clang -fobjc-arc -framework Foundation \ +// -I.. \ +// test_fileprovider_webdav.mm ../FileProviderWebDAV.mm \ +// -o /tmp/test_fp_webdav && /tmp/test_fp_webdav +// +#import +#import "FileProviderWebDAV.h" + +static int g_failures = 0; +static int g_checks = 0; + +#define CHECK(cond, msg) do { \ + g_checks++; \ + if (!(cond)) { g_failures++; fprintf(stderr, "FAIL: %s (%s:%d)\n", msg, __FILE__, __LINE__); } \ +} while (0) + +// Representative oCIS "spaces" PROPFIND Depth:1 response for a space root with +// a folder (umlaut, percent-encoded), a file, and a file whose name has a space. +static NSData *fixtureXML(void) { + NSString *xml = @"\n" + "\n" + " \n" + " /dav/spaces/74351999-70d1-4480-9a3f-f1cff123dfa6$1651695e-a108-1040-9584-abc777505ae3/\n" + " \n" + " 74351999$1651695e!root\n" + " \n" + " \"rootetag123\"\n" + " RDNVCK\n" + " HTTP/1.1 200 OK\n" + " \n" + " \n" + // Directory entry in the real-server shape: oc:id + oc:fileid identical, an + // oc:size, AND a separate 404 propstat for getcontentlength (dirs have none). + // The parser must commit only the 200 propstat and ignore the 404 block. + " /dav/spaces/74351999-70d1-4480-9a3f-f1cff123dfa6$1651695e-a108-1040-9584-abc777505ae3/Arbeitsvertr%C3%A4ge/\n" + " \n" + " 74351999$1651695e!17df2811\n" + " 74351999$1651695e!17df2811\n" + " \n" + " \"folderetag\"\n" + " Wed, 18 Feb 2026 15:23:00 GMT\n" + " RDNVCK\n" + " 0\n" + " HTTP/1.1 200 OK\n" + " \n" + " \n" + " HTTP/1.1 404 Not Found\n" + " \n" + " \n" + " /dav/spaces/74351999-70d1-4480-9a3f-f1cff123dfa6$1651695e-a108-1040-9584-abc777505ae3/LP_neu.md\n" + " \n" + " 74351999$1651695e!lpneu\n" + " \n" + " 1234\n" + " \"fileetag\"\n" + " Tue, 18 Feb 2026 16:42:00 GMT\n" + " HTTP/1.1 200 OK\n" + " \n" + " \n" + " /dav/spaces/74351999-70d1-4480-9a3f-f1cff123dfa6$1651695e-a108-1040-9584-abc777505ae3/Neue%20Datei.txt\n" + " \n" + " 74351999$1651695e!neue\n" + " \n" + " 0\n" + " \"emptyetag\"\n" + " HTTP/1.1 200 OK\n" + " \n" + "\n"; + return [xml dataUsingEncoding:NSUTF8StringEncoding]; +} + +static FileProviderWebDAVEntry *find(NSArray *entries, NSString *relPath) { + for (FileProviderWebDAVEntry *e in entries) { + if ([e.relativePath isEqualToString:relPath]) return e; + } + return nil; +} + +int main(void) { + @autoreleasepool { + NSString *prefix = @"/dav/spaces/74351999-70d1-4480-9a3f-f1cff123dfa6$1651695e-a108-1040-9584-abc777505ae3"; + NSError *err = nil; + NSArray *entries = + [FileProviderWebDAV parseMultistatus:fixtureXML() hrefPrefix:prefix error:&err]; + + CHECK(entries != nil, "parse returned nil"); + CHECK(err == nil, "parse set an error"); + CHECK(entries.count == 4, "expected 4 entries"); + + // Space root (self): relativePath "", directory. + FileProviderWebDAVEntry *root = find(entries, @""); + CHECK(root != nil, "self/root entry missing"); + CHECK(root.isDirectory, "root should be a directory"); + CHECK([root.fileId isEqualToString:@"74351999$1651695e!root"], "root fileId wrong"); + CHECK([root.etag isEqualToString:@"rootetag123"], "root etag quotes not stripped"); + + // Folder with umlaut: percent-decoded path + name. + FileProviderWebDAVEntry *av = find(entries, @"Arbeitsverträge"); + CHECK(av != nil, "Arbeitsverträge entry missing (percent-decoding?)"); + CHECK(av.isDirectory, "Arbeitsverträge should be a directory"); + CHECK([av.name isEqualToString:@"Arbeitsverträge"], "Arbeitsverträge name wrong"); + CHECK([av.fileId isEqualToString:@"74351999$1651695e!17df2811"], "folder fileId wrong"); + CHECK(av.modtime > 0, "folder modtime not parsed"); + // The 404 propstat for getcontentlength must not leak a size onto the dir. + CHECK(av.size == 0, "dir size should be 0 despite 404 getcontentlength block"); + + // Regular file with content length. + FileProviderWebDAVEntry *lp = find(entries, @"LP_neu.md"); + CHECK(lp != nil, "LP_neu.md entry missing"); + CHECK(!lp.isDirectory, "LP_neu.md should be a file"); + CHECK(lp.size == 1234, "LP_neu.md size wrong"); + CHECK(lp.modtime > 0, "file modtime not parsed"); + + // File whose name contains a space. + FileProviderWebDAVEntry *neue = find(entries, @"Neue Datei.txt"); + CHECK(neue != nil, "Neue Datei.txt entry missing (space decoding?)"); + CHECK(!neue.isDirectory, "Neue Datei.txt should be a file"); + CHECK(neue.size == 0, "Neue Datei.txt size should be 0"); + + if (g_failures == 0) { + printf("OK: %d checks passed\n", g_checks); + return 0; + } + fprintf(stderr, "%d/%d checks FAILED\n", g_failures, g_checks); + return 1; + } +} \ No newline at end of file diff --git a/src/extensions/fileprovider/tests/test_fileprovider_workingset_delta.mm b/src/extensions/fileprovider/tests/test_fileprovider_workingset_delta.mm new file mode 100644 index 0000000000..557e0df559 --- /dev/null +++ b/src/extensions/fileprovider/tests/test_fileprovider_workingset_delta.mm @@ -0,0 +1,186 @@ +// Standalone unit test for FileProviderWorkingSetDelta (pure working-set diff). +// +// Build & run: +// clang++ -fobjc-arc -framework Foundation -I.. \ +// test_fileprovider_workingset_delta.mm ../FileProviderWorkingSetDelta.mm \ +// -o /tmp/test_fp_ws && /tmp/test_fp_ws +// +#import +#import "FileProviderWorkingSetDelta.h" + +static int g_failures = 0; +static int g_checks = 0; + +#define CHECK(cond, msg) do { \ + g_checks++; \ + if (!(cond)) { g_failures++; fprintf(stderr, "FAIL: %s (%s:%d)\n", msg, __FILE__, __LINE__); } \ +} while (0) + +static NSDictionary *item(NSString *fileId, NSString *etag) { + return @{ @"fileId": fileId, @"etag": etag, @"filename": fileId, @"path": fileId }; +} + +// Item with an explicit path (for rename tests: same fileId+etag, different path). +static NSDictionary *itemP(NSString *fileId, NSString *etag, NSString *path) { + return @{ @"fileId": fileId, @"etag": etag, @"filename": path, @"path": path }; +} + +static BOOL containsId(NSArray *ids, NSString *fid) { + return [ids containsObject:fid]; +} + +static NSString *changedId(FPWorkingSetDelta *d, NSUInteger i) { + return d.changedItems[i][@"fileId"]; +} + +int main(void) { + @autoreleasepool { + // (1) First run: no previous snapshot -> everything is "changed", nothing deleted. + { + NSArray *current = @[ item(@"a", @"e1"), item(@"b", @"e1") ]; + FPWorkingSetDelta *d = FPComputeWorkingSetDelta(current, @[]); + CHECK(d.changedItems.count == 2, "first run: all items changed"); + CHECK(d.deletedFileIds.count == 0, "first run: nothing deleted"); + CHECK(d.currentFileIds.count == 2, "first run: 2 current ids"); + CHECK(containsId(d.currentFileIds, @"a") && containsId(d.currentFileIds, @"b"), + "first run: current ids a,b"); + } + + // (2) Identical second run: same fingerprints -> ZERO changed, zero deleted. (The churn fix.) + { + NSArray *prev = @[ item(@"a", @"e1"), item(@"b", @"e1") ]; + NSArray *current = @[ item(@"a", @"e1"), item(@"b", @"e1") ]; + FPWorkingSetDelta *d = FPComputeWorkingSetDelta(current, prev); + CHECK(d.changedItems.count == 0, "identical run: zero changed"); + CHECK(d.deletedFileIds.count == 0, "identical run: zero deleted"); + } + + // (3) One etag changed -> only that one reported. + { + NSArray *prev = @[ item(@"a", @"e1"), item(@"b", @"e1") ]; + NSArray *current = @[ item(@"a", @"e2"), item(@"b", @"e1") ]; + FPWorkingSetDelta *d = FPComputeWorkingSetDelta(current, prev); + CHECK(d.changedItems.count == 1, "one changed: count 1"); + CHECK([changedId(d, 0) isEqualToString:@"a"], "one changed: it's 'a'"); + CHECK(d.deletedFileIds.count == 0, "one changed: nothing deleted"); + } + + // (4) New item added -> only the new one reported. + { + NSArray *prev = @[ item(@"a", @"e1"), item(@"b", @"e1") ]; + NSArray *current = @[ item(@"a", @"e1"), item(@"b", @"e1"), item(@"c", @"e1") ]; + FPWorkingSetDelta *d = FPComputeWorkingSetDelta(current, prev); + CHECK(d.changedItems.count == 1, "new item: count 1"); + CHECK([changedId(d, 0) isEqualToString:@"c"], "new item: it's 'c'"); + } + + // (5) Item removed -> reported as deleted, nothing changed. + { + NSArray *prev = @[ item(@"a", @"e1"), item(@"b", @"e1") ]; + NSArray *current = @[ item(@"a", @"e1") ]; + FPWorkingSetDelta *d = FPComputeWorkingSetDelta(current, prev); + CHECK(d.changedItems.count == 0, "removed: zero changed"); + CHECK(d.deletedFileIds.count == 1, "removed: one deleted"); + CHECK([d.deletedFileIds[0] isEqualToString:@"b"], "removed: it's 'b'"); + } + + // (6) Mixed: change 'a', add 'd', delete 'c'. + { + NSArray *prev = @[ item(@"a", @"e1"), item(@"b", @"e1"), item(@"c", @"e1") ]; + NSArray *current = @[ item(@"a", @"e2"), item(@"b", @"e1"), item(@"d", @"e1") ]; + FPWorkingSetDelta *d = FPComputeWorkingSetDelta(current, prev); + CHECK(d.changedItems.count == 2, "mixed: 2 changed (a,d)"); + CHECK(d.deletedFileIds.count == 1 && [d.deletedFileIds[0] isEqualToString:@"c"], + "mixed: c deleted"); + } + + // (7) Empty current with previous -> everything deleted. + { + NSArray *prev = @[ item(@"a", @"e1"), item(@"b", @"e1") ]; + FPWorkingSetDelta *d = FPComputeWorkingSetDelta(@[], prev); + CHECK(d.changedItems.count == 0, "empty: zero changed"); + CHECK(d.deletedFileIds.count == 2, "empty: both deleted"); + } + + // (8) Item with empty etag previously, now has one -> treated as changed. + { + NSArray *prev = @[ item(@"a", @"") ]; + NSArray *current = @[ item(@"a", @"e1") ]; + FPWorkingSetDelta *d = FPComputeWorkingSetDelta(current, prev); + CHECK(d.changedItems.count == 1, "etag appeared: changed"); + } + + // (8b) THE RENAME BUG: same fileId, same etag, only the path/name changed. + // The old etag-only diff reported nothing; now it must be "changed". + { + NSArray *prev = @[ itemP(@"X", @"e1", @"apple_error_2.txt"), item(@"b", @"e1") ]; + NSArray *current = @[ itemP(@"X", @"e1", @"apple_error_FIXED.txt"), item(@"b", @"e1") ]; + FPWorkingSetDelta *d = FPComputeWorkingSetDelta(current, prev); + CHECK(d.changedItems.count == 1, "rename (same id+etag): one changed"); + CHECK([changedId(d, 0) isEqualToString:@"X"], "rename: it's 'X'"); + CHECK(d.deletedFileIds.count == 0, "rename: nothing deleted (same fileId)"); + } + + // ── FPItemSetSignature: the sync-anchor signature ────────────────── + // Regression: a server-side rename keeps child count and max(modtime) + // unchanged, so the old "count-maxmodtime" anchor never moved and Finder + // never re-enumerated. The signature must change on rename. + NSDictionary *(^it)(NSString *, NSString *, NSString *, long long) = + ^(NSString *fid, NSString *path, NSString *etag, long long modtime) { + return @{ @"fileId": fid, @"path": path, @"filename": path, + @"etag": etag, @"modtime": @(modtime) }; + }; + + // (9) Identical sets -> identical signature (stable, order-independent). + { + NSArray *a = @[ it(@"1", @"a.txt", @"e1", 100), it(@"2", @"b.txt", @"e1", 200) ]; + NSArray *b = @[ it(@"2", @"b.txt", @"e1", 200), it(@"1", @"a.txt", @"e1", 100) ]; + CHECK([FPItemSetSignature(a) isEqualToString:FPItemSetSignature(b)], + "signature: identical sets (any order) match"); + } + + // (10) THE BUG: rename keeps count AND max(modtime) but path changes. + // Old anchor (count-maxmodtime) would collide; signature must differ. + { + NSArray *before = @[ it(@"X", @"apple_error.txt", @"e1", 500), + it(@"Y", @"other.txt", @"e2", 999) ]; + NSArray *after = @[ it(@"X", @"apple_error_ren.txt", @"e1", 500), + it(@"Y", @"other.txt", @"e2", 999) ]; + // sanity: same count, same max modtime (the old anchor's inputs) + CHECK(before.count == after.count, "rename: same count"); + CHECK(![FPItemSetSignature(before) isEqualToString:FPItemSetSignature(after)], + "signature: rename (same count+maxmodtime) changes anchor"); + } + + // (11) Rename that also yields a NEW fileId (this server's behaviour). + { + NSArray *before = @[ it(@"oldid", @"f.txt", @"e1", 500) ]; + NSArray *after = @[ it(@"newid", @"f2.txt", @"e1", 500) ]; + CHECK(![FPItemSetSignature(before) isEqualToString:FPItemSetSignature(after)], + "signature: rename with new fileId changes anchor"); + } + + // (12) Delete and add still move the anchor. + { + NSArray *base = @[ it(@"1", @"a.txt", @"e1", 100), it(@"2", @"b.txt", @"e1", 200) ]; + NSArray *del = @[ it(@"1", @"a.txt", @"e1", 100) ]; + NSArray *add = @[ it(@"1", @"a.txt", @"e1", 100), it(@"2", @"b.txt", @"e1", 200), + it(@"3", @"c.txt", @"e1", 50) ]; + CHECK(![FPItemSetSignature(base) isEqualToString:FPItemSetSignature(del)], + "signature: delete changes anchor"); + CHECK(![FPItemSetSignature(base) isEqualToString:FPItemSetSignature(add)], + "signature: add changes anchor"); + } + + // (13) Pure content change (etag) moves the anchor. + { + NSArray *before = @[ it(@"1", @"a.txt", @"e1", 100) ]; + NSArray *after = @[ it(@"1", @"a.txt", @"e2", 100) ]; + CHECK(![FPItemSetSignature(before) isEqualToString:FPItemSetSignature(after)], + "signature: etag change moves anchor"); + } + + fprintf(stderr, "\n%d checks, %d failures\n", g_checks, g_failures); + return g_failures == 0 ? 0 : 1; + } +} diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 6f53259813..b8e08f851d 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -207,7 +207,21 @@ 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) + # Generate main app entitlements with the configured App Group identifier. + set(APP_GROUP_IDENTIFIER "${APPLE_DEVELOPMENT_TEAM}.${APPLICATION_REV_DOMAIN}") + configure_file( + "${CMAKE_SOURCE_DIR}/src/OpenCloud.entitlements" + "${CMAKE_CURRENT_BINARY_DIR}/OpenCloud.entitlements" + @ONLY + ) + set_target_properties(opencloud PROPERTIES + OUTPUT_NAME "${APPLICATION_SHORTNAME}" + MACOSX_BUNDLE_INFO_PLIST ${CMAKE_CURRENT_SOURCE_DIR}/MacOSXBundleInfo.plist + XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_CURRENT_BINARY_DIR}/OpenCloud.entitlements" + XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "$(CODE_SIGN_IDENTITY)" + XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "${APPLE_DEVELOPMENT_TEAM}" + XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME NO + ) endif() install(TARGETS opencloud OpenCloudGui ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) diff --git a/src/gui/application.cpp b/src/gui/application.cpp index 3cfe883ee4..3b91b1eb30 100644 --- a/src/gui/application.cpp +++ b/src/gui/application.cpp @@ -83,7 +83,13 @@ void setUpInitialSyncFolder(AccountStatePtr accountStatePtr, bool useVfs) if (!spaces.isEmpty()) { const QString localDir(accountStatePtr->account()->defaultSyncRoot()); FileSystem::setFolderMinimumPermissions(localDir); - Utility::setupFavLink(localDir); + // In NSFileProvider mode the spaces appear under Finder "Locations" + // automatically, and the legacy local sync root is only a backing + // skeleton (empty placeholder folders). Adding it to Favorites makes + // users trip over those empty folders — so skip it in NSFP mode. + if (VfsPluginManager::instance().bestAvailableVfsMode() != Vfs::Mode::MacOSNSFileProvider) { + Utility::setupFavLink(localDir); + } for (const auto *space : spaces) { const QString name = space->displayName(); const QString folderName = FolderMan::instance()->findGoodPathForNewSyncFolder( diff --git a/src/gui/folderman.cpp b/src/gui/folderman.cpp index ad7fae906a..519096b45b 100644 --- a/src/gui/folderman.cpp +++ b/src/gui/folderman.cpp @@ -35,6 +35,7 @@ #endif #include +#include #include using namespace Qt::Literals::StringLiterals; @@ -767,6 +768,17 @@ Folder *FolderMan::addFolderFromFolderWizardResult(const AccountStatePtr &accoun QString FolderMan::suggestSyncFolder(NewFolderType folderType, const QUuid &accountUuid) { +#ifdef Q_OS_MACOS + // NSFileProvider mode: the spaces are reached via Finder "Locations" (the + // CloudStorage mount). The on-disk sync root only backs the journal / change + // detection — it holds no user-visible files — so place it in a hidden + // Application Support location instead of cluttering the user's home with an + // ~/OpenCloud folder full of empty placeholder directories. + if (VfsPluginManager::instance().bestAvailableVfsMode() == Vfs::Mode::MacOSNSFileProvider) { + const QString hiddenBase = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + return FolderMan::instance()->findGoodPathForNewSyncFolder(hiddenBase, QStringLiteral("spaces"), folderType, accountUuid); + } +#endif return FolderMan::instance()->findGoodPathForNewSyncFolder(QDir::homePath(), Theme::instance()->appName(), folderType, accountUuid); } 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/propagatorjobs.cpp b/src/libsync/propagatorjobs.cpp index 78790884da..56958af5bb 100644 --- a/src/libsync/propagatorjobs.cpp +++ b/src/libsync/propagatorjobs.cpp @@ -148,6 +148,25 @@ void PropagateLocalMkdir::start() if (propagator()->_abortRequested) return; + // NSFileProvider mode: do NOT create a local directory skeleton on disk. The + // CloudStorage mount (served live by the extension) is the only user-facing + // representation; physical local dirs would just be confusing empty folders. + // Record the directory in the journal directly (the shared plist is built from + // it, driving the extension's change detection and browsing identity). We must + // bypass OwncloudPropagator::updateMetadata here because it skips writing the + // record when the local path does not exist. + if (const auto vfs = propagator()->syncOptions()._vfs; vfs && vfs->mode() == Vfs::Mode::MacOSNSFileProvider) { + auto record = SyncJournalFileRecord::fromSyncFileItem(*_item); + const auto dbResult = propagator()->_journal->setFileRecord(record); + if (!dbResult) { + done(SyncFileItem::FatalError, tr("Error updating metadata: %1").arg(dbResult.error())); + return; + } + propagator()->_journal->commit(QStringLiteral("localMkdir (nsfp, journal-only)")); + done(_item->instruction() == CSYNC_INSTRUCTION_CONFLICT ? SyncFileItem::Conflict : SyncFileItem::Success); + return; + } + QDir newDir(propagator()->fullLocalPath(_item->localName())); QString newDirStr = QDir::toNativeSeparators(newDir.path()); 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/fileprovider/CMakeLists.txt b/src/plugins/vfs/fileprovider/CMakeLists.txt new file mode 100644 index 0000000000..96df928486 --- /dev/null +++ b/src/plugins/vfs/fileprovider/CMakeLists.txt @@ -0,0 +1,7 @@ +if(APPLE) + add_vfs_plugin(NAME fileprovider + SRC + vfs_fileprovider.cpp + LIBS + ) +endif() diff --git a/src/plugins/vfs/fileprovider/vfs_fileprovider.cpp b/src/plugins/vfs/fileprovider/vfs_fileprovider.cpp new file mode 100644 index 0000000000..24d2db61cb --- /dev/null +++ b/src/plugins/vfs/fileprovider/vfs_fileprovider.cpp @@ -0,0 +1,285 @@ +/* + * SPDX-FileCopyrightText: 2025 OpenCloud GmbH and OpenCloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + * + * VFS plugin for macOS: Files on Demand via placeholder files and xattr + * (no OpenVFS). Placeholders are empty files with xattr: fileId, size, modtime, pinstate. + */ + +#include "vfs_fileprovider.h" + +#include "common/syncjournaldb.h" +#include "common/syncjournalfilerecord.h" +#include "filesystem.h" +#include "libsync/syncfileitem.h" +#include "libsync/theme.h" +#include "libsync/xattr.h" + +#include +#include +#include + +using namespace OCC; +using namespace Qt::StringLiterals; + +Q_LOGGING_CATEGORY(lcVfsFileProvider, "sync.vfs.fileprovider", QtInfoMsg) + +namespace { + +const QString XATTR_FILE_ID = QStringLiteral("eu.opencloud.desktop.vfs.fileid"); +const QString XATTR_SIZE = QStringLiteral("eu.opencloud.desktop.vfs.size"); +const QString XATTR_MODTIME = QStringLiteral("eu.opencloud.desktop.vfs.modtime"); +const QString XATTR_PLACEHOLDER = QStringLiteral("eu.opencloud.desktop.vfs.placeholder"); +const QString XATTR_PIN_STATE = QStringLiteral("eu.opencloud.desktop.vfs.pinstate"); + +bool hasPlaceholderXattr(const std::filesystem::path &path) +{ + const auto data = FileSystem::Xattr::getxattr(path, XATTR_PLACEHOLDER); + return data && *data == QByteArrayLiteral("1"); +} + +std::optional getPlaceholderFileId(const std::filesystem::path &path) +{ + return FileSystem::Xattr::getxattr(path, XATTR_FILE_ID); +} + +bool setPinStateXattr(const std::filesystem::path &path, PinState state) +{ + const auto result = FileSystem::Xattr::setxattr(path, XATTR_PIN_STATE, QByteArray::number(static_cast(state))); + return static_cast(result); +} + +Optional getPinStateXattr(const std::filesystem::path &path) +{ + const auto data = FileSystem::Xattr::getxattr(path, XATTR_PIN_STATE); + if (!data || data->isEmpty()) + return {}; + bool ok = false; + const int v = data->toInt(&ok); + if (!ok || v < 0 || v > 4) + return {}; + return static_cast(v); +} + +} // namespace + +VfsMacFileProvider::VfsMacFileProvider(QObject *parent) + : Vfs(parent) +{ +} + +VfsMacFileProvider::~VfsMacFileProvider() = default; + +Vfs::Mode VfsMacFileProvider::mode() const +{ + return Vfs::Mode::MacFileProvider; +} + +void VfsMacFileProvider::stop() +{ + for (auto *job : _hydrationJobs) + job->abort(); + _hydrationJobs.clear(); +} + +void VfsMacFileProvider::unregisterFolder() { } + +bool VfsMacFileProvider::socketApiPinStateActionsShown() const +{ + return true; +} + +Result VfsMacFileProvider::createPlaceholder(const SyncFileItem &item) +{ + const auto path = params().root() / item.localName(); + if (path.exists() && !item.isDirectory()) { + if (item._type == ItemTypeVirtualFileDehydration && FileSystem::fileChanged(path.get(), FileSystem::FileChangedInfo::fromSyncFileItem(&item))) { + return tr("The file has changed since discovery"); + } + } + QFile file(path.toString()); + if (!file.open(QFile::ReadWrite | QFile::Truncate)) { + return file.errorString(); + } + file.write(""); + file.close(); + + const auto fsPath = path.get(); + if (const auto r = FileSystem::Xattr::setxattr(fsPath, XATTR_PLACEHOLDER, QByteArrayLiteral("1")); !r) + return r.error(); + if (const auto r = FileSystem::Xattr::setxattr(fsPath, XATTR_FILE_ID, item._fileId); !r) + return r.error(); + if (const auto r = FileSystem::Xattr::setxattr(fsPath, XATTR_SIZE, QByteArray::number(item._size)); !r) + return r.error(); + if (const auto r = FileSystem::Xattr::setxattr(fsPath, XATTR_MODTIME, QByteArray::number(static_cast(item._modtime))); !r) + return r.error(); + FileSystem::setModTime(fsPath, item._modtime); + return {}; +} + +bool VfsMacFileProvider::needsMetadataUpdate(const SyncFileItem &item) +{ + const auto path = params().root() / item.localName(); + if (!path.exists()) + return false; + const auto fsPath = path.get(); + if (!hasPlaceholderXattr(fsPath)) + return false; + const auto sizeAttr = FileSystem::Xattr::getxattr(fsPath, XATTR_SIZE); + const auto modAttr = FileSystem::Xattr::getxattr(fsPath, XATTR_MODTIME); + if (!sizeAttr || sizeAttr->toLongLong() != item._size) + return true; + if (!modAttr || modAttr->toLongLong() != static_cast(item._modtime)) + return true; + return false; +} + +bool VfsMacFileProvider::isDehydratedPlaceholder(const QString &filePath) +{ + return hasPlaceholderXattr(FileSystem::toFilesystemPath(filePath)); +} + +LocalInfo VfsMacFileProvider::statTypeVirtualFile(const std::filesystem::directory_entry &path, ItemType type) +{ + if (type != ItemTypeFile) + return LocalInfo(path, type); + if (!hasPlaceholderXattr(path.path())) + return LocalInfo(path, type); + const auto pin = getPinStateXattr(path.path()); + if (pin == PinState::AlwaysLocal) + return LocalInfo(path, ItemTypeVirtualFileDownload); + return LocalInfo(path, ItemTypeVirtualFile); +} + +bool VfsMacFileProvider::setPinState(const QString &relFilePath, PinState state) +{ + const auto localPath = params().root() / relFilePath; + if (!localPath.exists()) { + qCWarning(lcVfsFileProvider) << "setPinState: path does not exist" << localPath.toString(); + return false; + } + return setPinStateXattr(localPath.get(), state); +} + +Optional VfsMacFileProvider::pinState(const QString &relFilePath) +{ + const auto localPath = params().root() / relFilePath; + if (!localPath.exists()) + return {}; + return getPinStateXattr(localPath.get()); +} + +Vfs::AvailabilityResult VfsMacFileProvider::availability(const QString &folderPath) +{ + const auto localPath = params().root() / folderPath; + if (!localPath.exists()) + return AvailabilityError::NoSuchItem; + const auto pin = getPinStateXattr(localPath.get()); + if (pin == PinState::AlwaysLocal) + return VfsItemAvailability::AlwaysLocal; + if (pin == PinState::OnlineOnly) + return VfsItemAvailability::OnlineOnly; + if (hasPlaceholderXattr(localPath.get())) + return VfsItemAvailability::AllDehydrated; + return VfsItemAvailability::Mixed; +} + +HydrationJob *VfsMacFileProvider::hydrateFile(const QByteArray &fileId, const QString &targetPath) +{ + qCInfo(lcVfsFileProvider) << "Requesting hydration for" << fileId; + if (_hydrationJobs.contains(fileId)) { + qCWarning(lcVfsFileProvider) << "Ignoring hydration request, already running for fileId" << fileId; + return nullptr; + } + if (!isDehydratedPlaceholder(targetPath)) { + qCWarning(lcVfsFileProvider) << "Path is not a placeholder:" << targetPath; + return nullptr; + } + auto *hydration = new HydrationJob(this, fileId, std::make_unique(targetPath), nullptr); + hydration->setTargetFile(targetPath); + _hydrationJobs.insert(fileId, hydration); + connect(hydration, &HydrationJob::finished, this, &VfsMacFileProvider::slotHydrateJobFinished); + connect(hydration, &HydrationJob::error, this, [this, hydration](const QString &error) { + qCWarning(lcVfsFileProvider) << "Hydration failed:" << error; + _hydrationJobs.remove(hydration->fileId()); + hydration->deleteLater(); + }); + return hydration; +} + +void VfsMacFileProvider::slotHydrateJobFinished() +{ + auto *hydration = qobject_cast(sender()); + if (!hydration) + return; + qCInfo(lcVfsFileProvider) << "Hydration finished for" << hydration->targetFileName(); + const auto targetPath = FileSystem::toFilesystemPath(hydration->targetFileName()); + if (std::filesystem::exists(targetPath)) { + auto item = SyncFileItem::fromSyncJournalFileRecord(hydration->record()); + item->_type = ItemTypeFile; + if (auto inode = FileSystem::getInode(targetPath)) + item->_inode = inode.value(); + const auto result = params().journal->setFileRecord(SyncJournalFileRecord::fromSyncFileItem(*item)); + if (!result) + qCWarning(lcVfsFileProvider) << "Error updating file record after hydration:" << result.error(); + if (FileSystem::Xattr::removexattr(targetPath, XATTR_PLACEHOLDER)) { } + if (FileSystem::Xattr::removexattr(targetPath, XATTR_FILE_ID)) { } + if (FileSystem::Xattr::removexattr(targetPath, XATTR_SIZE)) { } + if (FileSystem::Xattr::removexattr(targetPath, XATTR_MODTIME)) { } + } + _hydrationJobs.remove(hydration->fileId()); + hydration->deleteLater(); +} + +void VfsMacFileProvider::fileStatusChanged(const QString &systemFileName, SyncFileStatus fileStatus) +{ + if (fileStatus.tag() != SyncFileStatus::StatusExcluded) + return; + const auto absPath = FileSystem::toFilesystemPath(systemFileName); + const auto rootPath = params().root().get(); + std::error_code ec; + const auto relPath = std::filesystem::relative(absPath, rootPath, ec); + if (ec || relPath.empty()) + return; + setPinState(QString::fromStdString(relPath.generic_string()), PinState::Excluded); +} + +Result VfsMacFileProvider::updateMetadata( + const SyncFileItem &item, const QString &filePath, const QString &replacesFile) +{ + if (item._type == ItemTypeVirtualFileDehydration) { + if (const auto r = createPlaceholder(item); !r) + return r.error(); + return ConvertToPlaceholderResult::Ok; + } + const auto fsPath = FileSystem::toFilesystemPath(filePath); + if (!hasPlaceholderXattr(fsPath)) + return ConvertToPlaceholderResult::Ok; + if (const auto r = FileSystem::Xattr::setxattr(fsPath, XATTR_SIZE, QByteArray::number(item._size)); !r) + return r.error(); + if (const auto r = FileSystem::Xattr::setxattr(fsPath, XATTR_MODTIME, QByteArray::number(static_cast(item._modtime))); !r) + return r.error(); + FileSystem::setModTime(fsPath, item._modtime); + return ConvertToPlaceholderResult::Ok; +} + +void VfsMacFileProvider::startImpl(const VfsSetupParams ¶ms) +{ + Q_UNUSED(params); + Q_EMIT started(); +} + +Result FileProviderVfsPluginFactory::prepare(const QString &path, const QUuid &accountUuid) const +{ + Q_UNUSED(accountUuid); + const auto canonicalPath = FileSystem::canonicalPath(path); + const auto fsPath = FileSystem::toFilesystemPath(canonicalPath); + if (fsPath.empty()) { + return tr("The path is not valid."); + } + if (!FileSystem::Xattr::supportsxattr(fsPath)) { + return tr("The filesystem for %1 does not support extended attributes. Files on Demand requires a filesystem with xattr support (e.g. APFS).") + .arg(path); + } + return {}; +} diff --git a/src/plugins/vfs/fileprovider/vfs_fileprovider.h b/src/plugins/vfs/fileprovider/vfs_fileprovider.h new file mode 100644 index 0000000000..1918a731ea --- /dev/null +++ b/src/plugins/vfs/fileprovider/vfs_fileprovider.h @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2025 OpenCloud GmbH and OpenCloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +#pragma once + +#include "common/plugin.h" +#include "libsync/vfs/hydrationjob.h" +#include "libsync/vfs/vfs.h" + +#include + +namespace OCC { + +class VfsMacFileProvider : public Vfs +{ + Q_OBJECT + +public: + explicit VfsMacFileProvider(QObject *parent = nullptr); + ~VfsMacFileProvider() override; + + Mode mode() const override; + + void stop() override; + void unregisterFolder() override; + + bool socketApiPinStateActionsShown() const override; + + Result createPlaceholder(const SyncFileItem &item) override; + + bool needsMetadataUpdate(const SyncFileItem &item) override; + bool isDehydratedPlaceholder(const QString &filePath) override; + LocalInfo statTypeVirtualFile(const std::filesystem::directory_entry &path, ItemType type) override; + + bool setPinState(const QString &relFilePath, PinState state) override; + Optional pinState(const QString &relFilePath) override; + AvailabilityResult availability(const QString &folderPath) override; + + HydrationJob *hydrateFile(const QByteArray &fileId, const QString &targetPath) override; + +public Q_SLOTS: + void fileStatusChanged(const QString &systemFileName, SyncFileStatus fileStatus) override; + +private Q_SLOTS: + void slotHydrateJobFinished(); + +protected: + Result updateMetadata(const SyncFileItem &item, const QString &filePath, const QString &replacesFile) override; + void startImpl(const VfsSetupParams ¶ms) override; + +private: + QMap _hydrationJobs; +}; + +class FileProviderVfsPluginFactory : public QObject, public DefaultPluginFactory +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "eu.opencloud.PluginFactory" FILE "libsync/vfs/vfspluginmetadata.json") + Q_INTERFACES(OCC::PluginFactory) +public: + Result prepare(const QString &path, const QUuid &accountUuid) const override; +}; + +} // namespace OCC 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..643888c249 --- /dev/null +++ b/src/plugins/vfs/nsfp/nsfpdomainmanager.h @@ -0,0 +1,98 @@ +// 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. + /// @param forceRecreate If true and the domain already exists, it is REMOVED and + /// re-created from scratch. Use this once (gated by a marker) to clear a + /// corrupted fileproviderd replica/FPFS state (FPCK failures, stuck pending + /// import operations) that jams new Finder operations. + void addDomain(const QString &identifier, const QString &displayName, NsfpDomainCompletionHandler completionHandler, bool forceRecreate = false); + + /// 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 working set enumerator. This covers ALL items across all + /// folders and is critical for detecting deletions in subdirectories. + void signalWorkingSet(const QString &identifier); + + /// Signal the system to perform storage-pressure eviction. + /// The framework will decide which items to evict based on their + /// allowsEviction capability and last-access timestamps. + /// @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; + // shared_ptr (not unique_ptr): async NSFileProviderManager completion handlers + // capture a copy so that Private (its caches/mutex/queue) stays alive even if + // this manager is destroyed while a completion is still in flight (e.g. when a + // space is removed). Capturing raw `this`/`_p` there caused a use-after-free + // crash on the FileProvider XPC queue. + std::shared_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..0475916c41 --- /dev/null +++ b/src/plugins/vfs/nsfp/nsfpdomainmanager.mm @@ -0,0 +1,521 @@ +// 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_shared()) +{ +} + +NsfpDomainManager::~NsfpDomainManager() +{ + // Drain the serial queue so all pending blocks finish before this object is + // freed. In-flight NSFileProviderManager completion handlers capture their + // own shared_ptr copy (see below), so they remain safe even after + // the manager is destroyed. + 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, bool forceRecreate) +{ + 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 a shared_ptr copy so Private survives async completions that may + // fire after this manager is destroyed. + auto p = _p; + + // 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; + } + // NOTE: Do NOT remove other "opencloud*" domains here. Every space + // is its own domain sharing the "opencloud--" prefix, + // so treating siblings as "stale" deletes all previously-registered + // spaces whenever a new space is added (only the last one survives). + // Orphaned-domain cleanup happens explicitly via removeDomain() when a + // space is unsynced, not as a side effect of adding a different space. + } + } + dispatch_semaphore_signal(listSemaphore); + }]; + dispatch_semaphore_wait(listSemaphore, DISPATCH_TIME_FOREVER); + + if (listError) { + qCWarning(lcNsfpDomainManager) << "Failed to list existing domains:" << QString::fromNSString(listError.localizedDescription); + } + + // One-time corruption recovery: if asked to force-recreate and the domain + // exists, remove it (discarding the corrupted replica/FPFS) so the create + // path below registers it fresh. Gated by a per-domain marker in the caller + // so this happens at most once per update. + if (existingDomain && forceRecreate) { + qCWarning(lcNsfpDomainManager) << "forceRecreate: removing existing domain to clear corrupted state:" << identifierCopy; + dispatch_semaphore_t recreateSem = dispatch_semaphore_create(0); + [NSFileProviderManager removeDomain:existingDomain completionHandler:^(NSError *rmErr) { + if (rmErr) { + qCWarning(lcNsfpDomainManager) << "forceRecreate remove failed:" + << QString::fromNSString(rmErr.localizedDescription); + } else { + qCInfo(lcNsfpDomainManager) << "forceRecreate: domain removed, will re-create fresh:" << identifierCopy; + } + dispatch_semaphore_signal(recreateSem); + }]; + dispatch_semaphore_wait(recreateSem, DISPATCH_TIME_FOREVER); + existingDomain = nil; // fall through to the create path below + } + + // If the domain already exists, reuse it rather than removing and re-adding. + // Unconditional remove-then-readd on every launch causes Finder to briefly drop + // the Locations entry; if addDomain then fails for any transient reason the + // entry is gone permanently until the next launch. + if (existingDomain) { + qCInfo(lcNsfpDomainManager) << "Domain already registered, attempting reuse:" << identifierCopy + << "userEnabled:" << existingDomain.userEnabled; + + NSFileProviderManager *existingManager = [NSFileProviderManager managerForDomain:existingDomain]; + if (existingManager) { + // Domain is healthy — wake the extension by forcing a full re-enumeration. + [existingManager reimportItemsBelowItemWithIdentifier:NSFileProviderRootContainerItemIdentifier + completionHandler:^(NSError *reimportErr) { + if (reimportErr) { + qCWarning(lcNsfpDomainManager) << "Reimport (domain reuse) failed:" + << QString::fromNSString(reimportErr.localizedDescription); + } else { + qCInfo(lcNsfpDomainManager) << "Reimport (domain reuse) succeeded"; + } + }]; + + { + QMutexLocker lock(&p->cacheMutex); + p->domainCache[identifierCopy] = existingDomain; + p->managerCache[identifierCopy] = existingManager; + } + if (handler) { + handler(QString()); // success — domain reused + } + return; + } + + // No manager available — domain is in a broken state. Remove and re-add + // as recovery so fileproviderd picks up a fresh extension registration. + qCWarning(lcNsfpDomainManager) << "Domain exists but manager unavailable, removing for re-add:" << identifierCopy; + dispatch_semaphore_t removeSem = dispatch_semaphore_create(0); + [NSFileProviderManager removeDomain:existingDomain completionHandler:^(NSError *removeErr) { + if (removeErr) { + qCWarning(lcNsfpDomainManager) << "Failed to remove broken domain for re-add:" + << QString::fromNSString(removeErr.localizedDescription); + } else { + qCInfo(lcNsfpDomainManager) << "Broken 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. + } + + // 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 p = _p; + 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; + auto p = _p; + + 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(); + auto p = _p; + + 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::signalWorkingSet(const QString &identifier) +{ + qCInfo(lcNsfpDomainManager) << "signalWorkingSet requested for domain:" << identifier; + + QString identifierCopy = identifier; + auto p = _p; + dispatch_async(p->dispatchQueue, ^{ + NSFileProviderManager *manager = nil; + { + QMutexLocker lock(&p->cacheMutex); + manager = p->managerCache.value(identifierCopy, nil); + } + + if (!manager) { + qCWarning(lcNsfpDomainManager) << "signalWorkingSet: no manager for domain:" << identifierCopy; + return; + } + + [manager signalEnumeratorForContainerItemIdentifier:NSFileProviderWorkingSetContainerItemIdentifier + completionHandler:^(NSError *error) { + if (error) { + qCWarning(lcNsfpDomainManager) << "signalWorkingSet failed:" + << QString::fromNSString(error.localizedDescription); + } else { + qCDebug(lcNsfpDomainManager) << "signalWorkingSet succeeded for domain:" << identifierCopy; + } + }]; + }); +} + +void NsfpDomainManager::evictItem(const QString &identifier, const QString &fileId, + NsfpDomainCompletionHandler completionHandler) +{ + qCInfo(lcNsfpDomainManager) << "evictItem requested for domain:" << identifier + << "fileId:" << fileId; + + QString identifierCopy = identifier; + QString fileIdCopy = fileId; + NSString *nsFileId = fileId.toNSString(); + auto p = _p; + 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; + auto p = _p; + + 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..29e52278e4 --- /dev/null +++ b/src/plugins/vfs/nsfp/vfs_nsfp.mm @@ -0,0 +1,1123 @@ +// 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 + +#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, const QString &domainId) +{ + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) { + qCWarning(lcVfsNSFP) << "syncConfigToSharedContainer: cannot access App Group container"; + return; + } + + // Per-domain config file so multiple accounts/spaces don't overwrite each other. + NSString *configFilename = [NSString stringWithFormat:@"fileprovider_config_%@.plist", + domainId.toNSString()]; + + // Extract access token from credentials. + QString accessToken; + if (auto *httpCreds = qobject_cast(params.account->credentials())) { + accessToken = httpCreds->accessToken(); + } + + if (accessToken.isEmpty()) { + NSURL *existingConfig = [containerURL URLByAppendingPathComponent:configFilename]; + 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)"; + } + + auto davUrl = params.baseUrl().toString(QUrl::FullyEncoded); + davUrl.replace(QLatin1Char('$'), QStringLiteral("%24")); + + NSDictionary *config = @{ + @"davUrl": davUrl.toNSString(), + @"accessToken": accessToken.toNSString(), + }; + + NSURL *configURL = [containerURL URLByAppendingPathComponent:configFilename]; + 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; + } + + [data writeToURL:configURL atomically:YES]; + [[NSFileManager defaultManager] setAttributes:@{NSFilePosixPermissions: @0644} + ofItemAtPath:configURL.path + error:nil]; + qCInfo(lcVfsNSFP) << "syncConfigToSharedContainer: wrote config for domain" << domainId << "davUrl" << davUrl; + + // One-time cleanup: remove legacy global files (and their stale caches) + // only if they still exist. Once removed, this block is a no-op. + { + NSFileManager *fm = [NSFileManager defaultManager]; + NSURL *legacyConfig = [containerURL URLByAppendingPathComponent:@"fileprovider_config.plist"]; + NSURL *legacyItems = [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"]; + BOOL hasLegacy = [fm fileExistsAtPath:legacyConfig.path] + || [fm fileExistsAtPath:legacyItems.path]; + if (hasLegacy) { + [fm removeItemAtURL:legacyConfig error:nil]; + [fm removeItemAtURL:legacyItems error:nil]; + qCInfo(lcVfsNSFP) << "Removed legacy plist files"; + // Also remove stale prevFileIds caches from the legacy era. + NSArray *contents = [fm contentsOfDirectoryAtPath:containerURL.path error:nil]; + for (NSString *name in contents) { + if ([name hasPrefix:@"prevFileIds_"]) { + [fm removeItemAtURL:[containerURL URLByAppendingPathComponent:name] error:nil]; + qCInfo(lcVfsNSFP) << "Removed stale cache:" << QString::fromNSString(name); + } + } + } + } +} + +/// Writes all file records from the sync journal to a plist file in the +/// App Group shared container so the FileProvider extension can enumerate items +/// without needing an XPC connection to the main app. +static void syncMetadataToSharedContainer(SyncJournalDb *journal, const VfsSetupParams ¶ms, const QString &domainId) +{ + 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_info(OS_LOG_DEFAULT, "syncMetadataToSharedContainer: callbacks=%d invalid=%d dirs=%d virtualFiles=%d other=%d records=%d", + totalCallbacks, invalidCount, dirCount, virtualFileCount, otherCount, (int)records.size()); + + // If the journal query returned no virtual files, they may have been deleted + // 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); + } + } + + // Compute the davUrl for this space so each item carries its own WebDAV + // base URL. This prevents cross-space confusion when multiple spaces are + // synced simultaneously (each space has a different davUrl). + auto davUrl = params.baseUrl().toString(QUrl::FullyEncoded); + davUrl.replace(QLatin1Char('$'), QStringLiteral("%24")); + NSString *nsDavUrl = davUrl.toNSString(); + + // Second pass: resolve parent file IDs and build the plist array. + NSMutableArray *items = [NSMutableArray arrayWithCapacity:records.size()]; + for (const auto &info : records) { + 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" : @(info.isDirectory || !info.isVirtualFile), + @"davUrl" : nsDavUrl, + }; + [items addObject:dict]; + } + + // Preserve items that were added by the FileProvider extension (e.g. via + // createItemBasedOnTemplate) but haven't been synced to the journal yet. + // Without this merge, syncMetadataToSharedContainer would overwrite the + // plist and the extension-created items would be reported as deleted by + // the enumerator's change-detection diff. + NSString *itemsFilename = [NSString stringWithFormat:@"fileprovider_items_%@.plist", + domainId.toNSString()]; + NSURL *metadataURL = [containerURL URLByAppendingPathComponent:itemsFilename]; + { + NSData *existingData = [NSData dataWithContentsOfURL:metadataURL]; + if (existingData) { + NSArray *existingItems = [NSPropertyListSerialization propertyListWithData:existingData + options:NSPropertyListImmutable + format:nil error:nil]; + if ([existingItems isKindOfClass:[NSArray class]]) { + // Build a set of fileIds from the journal so we can quickly + // check whether an existing plist entry is already covered. + NSMutableSet *journalFileIds = [NSMutableSet setWithCapacity:items.count]; + // Also track paths to detect items at the same location. + NSMutableSet *journalPaths = [NSMutableSet setWithCapacity:items.count]; + for (NSDictionary *item in items) { + [journalFileIds addObject:item[@"fileId"] ?: @""]; + [journalPaths addObject:item[@"path"] ?: @""]; + } + + // Only preserve extension-created items that are recent (< 120s). + // After that the sync engine should have discovered them. If they + // are still not in the journal, they were deleted on the server. + int64_t now = (int64_t)[[NSDate date] timeIntervalSince1970]; + static const int64_t MAX_PRESERVE_AGE = 120; // seconds + + int preservedCount = 0; + for (NSDictionary *existing in existingItems) { + BOOL isExtCreated = [existing[@"extensionCreated"] boolValue]; + int64_t movedAt = [existing[@"movedAt"] longLongValue]; + + if (!isExtCreated && movedAt == 0) { + // Regular journal item — not preserved. + continue; + } + + // Check TTL for both extension-created and moved items. + int64_t timestamp = isExtCreated + ? [existing[@"extensionCreatedAt"] longLongValue] + : movedAt; + if (timestamp > 0 && (now - timestamp) > MAX_PRESERVE_AGE) { + continue; + } + + NSString *existingId = existing[@"fileId"] ?: @""; + NSString *existingPath = existing[@"path"] ?: @""; + if (![journalFileIds containsObject:existingId] + && ![journalPaths containsObject:existingPath]) { + [items addObject:existing]; + preservedCount++; + } + } + if (preservedCount > 0) { + qCInfo(lcVfsNSFP) << "syncMetadataToSharedContainer: preserved" + << preservedCount << "extension-created items not yet in journal"; + } + } + } + } + + NSError *writeError = nil; + NSData *data = [NSPropertyListSerialization dataWithPropertyList:items + format:NSPropertyListBinaryFormat_v1_0 + 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); + } +} + +/// Reads the metadata plist for the given domain and returns the set of unique +/// parent container identifiers (folder fileIds). Used to determine which folder +/// enumerators need signalling after a sync cycle so that deletions in +/// subdirectories are detected. +static QSet collectParentContainerIds(const QString &domainId) +{ + QSet result; + + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (!containerURL) return result; + + NSString *filename = [NSString stringWithFormat:@"fileprovider_items_%@.plist", + domainId.toNSString()]; + NSURL *url = [containerURL URLByAppendingPathComponent:filename]; + NSData *data = [NSData dataWithContentsOfURL:url]; + if (!data) return result; + + NSArray *items = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListImmutable + format:nil error:nil]; + if (![items isKindOfClass:[NSArray class]]) return result; + + for (NSDictionary *item in items) { + NSString *parentId = item[@"parentId"]; + if (parentId.length > 0 + && ![parentId isEqualToString:NSFileProviderRootContainerItemIdentifier]) { + result.insert(QString::fromNSString(parentId)); + } + } + + return result; +} + +VfsNSFP::VfsNSFP(QObject *parent) + : Vfs(parent) +{ +} + +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, params(), _domainId); + + // 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, params(), _domainId); + + // 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(), self->_domainId); + } + }); + + // One-time corruption recovery: clear a possibly-corrupted fileproviderd replica + // (FPCK failures, stuck pending import operations that jam new Finder ops) by + // force-recreating the domain ONCE after this update. Gated by a per-domain marker + // in the App Group container so it happens at most once. + BOOL forceRecreate = NO; + NSURL *resetMarker = nil; + { + NSURL *grp = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier]; + if (grp) { + resetMarker = [grp URLByAppendingPathComponent: + [NSString stringWithFormat:@"fp_reset_v3_%@", _domainId.toNSString()]]; + forceRecreate = ![[NSFileManager defaultManager] fileExistsAtPath:resetMarker.path]; + if (forceRecreate) { + qCWarning(lcVfsNSFP) << "One-time domain reset (clearing corrupted state) for:" << _domainId; + } + } + } + + _domainManager->addDomain(_domainId, displayName, [self, resetMarker](const QString &errorMessage) { + if (!self) { + return; + } + + if (errorMessage.isEmpty()) { + // Mark the one-time reset as done so it never repeats. + if (resetMarker) { + [@"done" writeToURL:resetMarker atomically:YES encoding:NSUTF8StringEncoding error:nil]; + } + 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, self->params(), self->_domainId); + + // Write WebDAV URL + access token so extension can download directly. + // The fetched() signal connection was already established before + // addDomain to avoid race conditions. + syncConfigToSharedContainer(self->params(), self->_domainId); + + // 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"; + + // Collect parent container IDs BEFORE updating the plist. + // This captures containers that currently have items — if items + // are removed (remote delete), the container's enumerator must + // be signalled so it can detect the deletion via its prevFileIds diff. + const auto oldParentIds = collectParentContainerIds(self->_domainId); + + syncMetadataToSharedContainer(self->params().journal, self->params(), self->_domainId); + syncConfigToSharedContainer(self->params(), self->_domainId); + + // Collect parent container IDs AFTER updating the plist. + const auto newParentIds = collectParentContainerIds(self->_domainId); + + // Signal all affected parent containers (union of old and new). + // Old containers need signalling to detect item deletions/moves-out. + // New containers need signalling to detect item additions/moves-in. + auto allParentIds = oldParentIds; + allParentIds.unite(newParentIds); + + if (!allParentIds.isEmpty()) { + qCInfo(lcVfsNSFP) << "Signalling" << allParentIds.size() + << "parent container enumerators after sync"; + } + for (const auto &parentId : allParentIds) { + self->_domainManager->signalEnumerator(self->_domainId, parentId); + } + + // Signal root container. + self->_domainManager->signalEnumerator(self->_domainId, QString()); + + // Signal working set — this enumerator covers ALL items across + // all folders and is the most reliable way to detect deletions + // in subdirectories (its prevFileIds cache spans everything). + self->_domainManager->signalWorkingSet(self->_domainId); + + self->_domainManager->requestSystemEviction(self->_domainId); + }); + } + + // 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(), self->_domainId); + // 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); + } + }, forceRecreate); +} + +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