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