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..c3d6be3732
--- /dev/null
+++ b/src/extensions/fileprovider/CMakeLists.txt
@@ -0,0 +1,127 @@
+# CMake build configuration for the macOS File Provider App Extension.
+# Builds an .appex bundle implementing NSFileProviderReplicatedExtension
+# for Files On Demand support on macOS 12+.
+
+if(APPLE)
+ enable_language(OBJCXX)
+
+ # Generate entitlements with the configured App Group identifier.
+ set(APP_GROUP_IDENTIFIER "${APPLE_DEVELOPMENT_TEAM}.${APPLICATION_REV_DOMAIN}")
+ configure_file(
+ "${CMAKE_CURRENT_SOURCE_DIR}/OpenCloudFileProvider.entitlements"
+ "${CMAKE_CURRENT_BINARY_DIR}/OpenCloudFileProvider.entitlements"
+ @ONLY
+ )
+
+ set(FILEPROVIDER_EXTENSION_SOURCES
+ OpenCloudFileProviderExtension.mm
+ FileProviderXPCService.mm
+ FileProviderItem.mm
+ FileProviderEnumerator.mm
+ FileProviderThumbnails.mm
+ )
+
+ set(FILEPROVIDER_EXTENSION_HEADERS
+ OpenCloudFileProviderExtension.h
+ FileProviderXPCService.h
+ FileProviderItem.h
+ FileProviderEnumerator.h
+ FileProviderThumbnails.h
+ )
+
+ # Must be add_executable (not add_library MODULE) so the Mach-O filetype
+ # is MH_EXECUTE (2) rather than MH_BUNDLE (8). macOS requires app
+ # extensions to be executables for sandbox entitlements to be embedded.
+ add_executable(OpenCloudFileProviderExtension
+ ${FILEPROVIDER_EXTENSION_SOURCES}
+ ${FILEPROVIDER_EXTENSION_HEADERS}
+ )
+
+ # Mark as an App Extension bundle (.appex)
+ set_target_properties(OpenCloudFileProviderExtension PROPERTIES
+ BUNDLE_EXTENSION "appex"
+ MACOSX_BUNDLE TRUE
+ MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist.in"
+ MACOSX_BUNDLE_BUNDLE_NAME "OpenCloudFileProvider"
+ MACOSX_BUNDLE_BUNDLE_VERSION "${MIRALL_VERSION_FULL}"
+ MACOSX_BUNDLE_SHORT_VERSION_STRING "${MIRALL_VERSION}"
+ MACOSX_BUNDLE_GUI_IDENTIFIER "${APPLICATION_REV_DOMAIN}.fileprovider"
+ XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_CURRENT_BINARY_DIR}/OpenCloudFileProvider.entitlements"
+ XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "$(CODE_SIGN_IDENTITY)"
+ XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "${APPLE_DEVELOPMENT_TEAM}"
+ XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${APPLICATION_REV_DOMAIN}.fileprovider"
+ XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME YES
+ )
+
+ # Pass the App Group identifier to source code.
+ target_compile_definitions(OpenCloudFileProviderExtension PRIVATE
+ APP_GROUP_IDENTIFIER="${APP_GROUP_IDENTIFIER}"
+ )
+
+ # App Extension compile flag: restricts API surface to extension-safe APIs
+ target_compile_options(OpenCloudFileProviderExtension PRIVATE
+ -fapplication-extension
+ -fobjc-arc
+ )
+
+ # Link against required Apple frameworks
+ target_link_libraries(OpenCloudFileProviderExtension PRIVATE
+ "-framework CoreGraphics"
+ "-framework Foundation"
+ "-framework FileProvider"
+ "-framework UniformTypeIdentifiers"
+ )
+
+ # Linker flag for extension-safe linking
+ target_link_options(OpenCloudFileProviderExtension PRIVATE
+ -fapplication-extension
+ -e _NSExtensionMain
+ )
+
+ # Set deployment target to macOS 12+ (minimum for NSFileProviderReplicatedExtension)
+ set_target_properties(OpenCloudFileProviderExtension PROPERTIES
+ XCODE_ATTRIBUTE_MACOSX_DEPLOYMENT_TARGET "12.0"
+ )
+
+ # Embed the extension in the main application bundle under PlugIns/
+ # XCODE_EMBED_APP_EXTENSIONS only works with Xcode generator.
+ # For Ninja/Makefile generators, use a post-build copy step.
+ if(TARGET ${APPLICATION_EXECUTABLE})
+ if(CMAKE_GENERATOR STREQUAL "Xcode")
+ set_target_properties(${APPLICATION_EXECUTABLE} PROPERTIES
+ XCODE_EMBED_APP_EXTENSIONS OpenCloudFileProviderExtension
+ )
+ else()
+ add_custom_command(TARGET OpenCloudFileProviderExtension POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E copy_directory
+ "$"
+ "$/../PlugIns/OpenCloudFileProviderExtension.appex"
+ COMMENT "Embedding FileProviderExtension.appex into app bundle"
+ )
+ # Sign the extension with entitlements (XCODE_ATTRIBUTE_* only works
+ # with the Xcode generator, so Ninja/Make need an explicit codesign).
+ if(DEFINED CODESIGN_IDENTITY AND NOT CODESIGN_IDENTITY STREQUAL "")
+ add_custom_command(TARGET OpenCloudFileProviderExtension POST_BUILD
+ COMMAND codesign --force --sign "${CODESIGN_IDENTITY}"
+ --entitlements "${CMAKE_CURRENT_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/FileProviderEnumerator.h b/src/extensions/fileprovider/FileProviderEnumerator.h
new file mode 100644
index 0000000000..4b274e33bc
--- /dev/null
+++ b/src/extensions/fileprovider/FileProviderEnumerator.h
@@ -0,0 +1,28 @@
+// FileProviderEnumerator -- NSFileProviderEnumerator implementation that serves
+// directory listings to the macOS File Provider system via XPC.
+#pragma once
+
+#import
+#import
+
+@class FileProviderXPCService;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// Enumerates items within a given container (directory) for the File Provider
+/// framework. Obtains item listings from the main application via XPC since
+/// the extension runs in a separate process without direct sync journal access.
+API_AVAILABLE(macos(12.0))
+@interface FileProviderEnumerator : NSObject
+
+/// Designated initializer.
+/// @param containerId The identifier of the container to enumerate
+/// (e.g. root container or a folder's file ID).
+/// @param service The XPC service used to communicate with the main app.
+- (instancetype)initWithContainerIdentifier:(NSFileProviderItemIdentifier)containerId
+ xpcService:(FileProviderXPCService *)service
+ domain:(NSFileProviderDomain *)domain;
+
+@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..43030b9571
--- /dev/null
+++ b/src/extensions/fileprovider/FileProviderEnumerator.mm
@@ -0,0 +1,321 @@
+// FileProviderEnumerator -- NSFileProviderEnumerator implementation.
+// Serves directory listings to Finder by reading file metadata from the
+// App Group shared container (written by the main app's sync engine).
+
+#import "FileProviderEnumerator.h"
+
+#import "FileProviderItem.h"
+#import "FileProviderXPCService.h"
+
+#import
+
+static os_log_t enumeratorLog(void) {
+ static os_log_t log = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ log = os_log_create("eu.opencloud.desktop.fileprovider", "enumerator");
+ });
+ return log;
+}
+
+/// Appends a trace line to the debug log file in the App Group container.
+static void appendTrace(NSString *line) {
+ NSURL *container = [[NSFileManager defaultManager]
+ containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier];
+ if (!container) return;
+ NSString *path = [[container URLByAppendingPathComponent:@"fp_debug.log"] path];
+ NSFileHandle *fh = [NSFileHandle fileHandleForWritingAtPath:path];
+ if (!fh) {
+ [[NSFileManager defaultManager] createFileAtPath:path contents:nil attributes:nil];
+ fh = [NSFileHandle fileHandleForWritingAtPath:path];
+ }
+ [fh seekToEndOfFile];
+ [fh writeData:[line dataUsingEncoding:NSUTF8StringEncoding]];
+ [fh closeFile];
+}
+
+/// Reads the shared metadata plist from the App Group container.
+/// Uses per-domain file if available, falls back to legacy global file.
+static NSArray *readSharedMetadata(NSFileProviderDomain *domain) {
+ NSURL *containerURL = [[NSFileManager defaultManager]
+ containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier];
+ if (!containerURL) {
+ os_log_error(enumeratorLog(), "Cannot access App Group container");
+ return nil;
+ }
+
+ // Try per-domain file first, fall back to legacy.
+ NSString *perDomainName = [NSString stringWithFormat:@"fileprovider_items_%@.plist", domain.identifier];
+ NSURL *perDomainURL = [containerURL URLByAppendingPathComponent:perDomainName];
+ NSURL *metadataURL = [[NSFileManager defaultManager] fileExistsAtPath:perDomainURL.path]
+ ? perDomainURL
+ : [containerURL URLByAppendingPathComponent:@"fileprovider_items.plist"];
+ NSData *data = [NSData dataWithContentsOfURL:metadataURL];
+ if (!data) {
+ os_log_error(enumeratorLog(), "Shared metadata file not found at: %{public}@", metadataURL.path);
+ return nil;
+ }
+
+ NSError *readError = nil;
+ NSArray *items = [NSPropertyListSerialization propertyListWithData:data
+ options:NSPropertyListImmutable
+ format:nil
+ error:&readError];
+ if (!items || readError) {
+ os_log_error(enumeratorLog(), "Failed to read shared metadata: %{public}@",
+ readError.localizedDescription);
+ return nil;
+ }
+
+ return items;
+}
+
+#pragma mark - FileProviderEnumerator
+
+API_AVAILABLE(macos(12.0))
+@implementation FileProviderEnumerator {
+ NSFileProviderItemIdentifier _containerId;
+ FileProviderXPCService *_xpcService;
+ NSFileProviderDomain *_domain;
+ BOOL _invalidated;
+}
+
+- (instancetype)initWithContainerIdentifier:(NSFileProviderItemIdentifier)containerId
+ xpcService:(FileProviderXPCService *)service
+ domain:(NSFileProviderDomain *)domain {
+ self = [super init];
+ if (self) {
+ _containerId = [containerId copy];
+ _xpcService = service;
+ _domain = domain;
+ _invalidated = NO;
+
+ os_log_debug(enumeratorLog(), "Enumerator CREATED for container: %{public}@", containerId);
+ }
+ return self;
+}
+
+#pragma mark - NSFileProviderEnumerator
+
+- (void)enumerateItemsForObserver:(id)observer
+ startingAtPage:(NSFileProviderPage)page {
+ // Use fault-level logging to ensure it's always persisted
+ os_log_debug(enumeratorLog(), "enumerateItems CALLED container=%{public}@ invalidated=%d", _containerId, _invalidated);
+
+ appendTrace([NSString stringWithFormat:@"[%@] enumerateItems container=%@ invalidated=%d\n",
+ [NSDate date], _containerId, _invalidated]);
+
+ if (_invalidated) {
+ [observer finishEnumeratingWithError:
+ [NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorServerUnreachable
+ userInfo:@{NSLocalizedDescriptionKey: @"Enumerator has been invalidated"}]];
+ return;
+ }
+
+ os_log_info(enumeratorLog(), "enumerateItems container=%{public}@", _containerId);
+
+ // Read file metadata from the App Group shared container.
+ NSArray *allItems = readSharedMetadata(_domain);
+ if (!allItems) {
+ os_log_error(enumeratorLog(), "No shared metadata available — main app may not be running");
+ [observer didEnumerateItems:@[]];
+ [observer finishEnumeratingWithError:nil];
+ return;
+ }
+
+ // Determine which items belong to this container.
+ NSString *targetParentPath = nil;
+
+ if ([_containerId isEqualToString:NSFileProviderRootContainerItemIdentifier]) {
+ // Root container: items whose parentPath is empty.
+ targetParentPath = @"";
+ } else if ([_containerId isEqualToString:NSFileProviderWorkingSetContainerItemIdentifier]) {
+ // Working set: return all items.
+ targetParentPath = nil; // nil means "all items"
+ } else if ([_containerId isEqualToString:NSFileProviderTrashContainerItemIdentifier]) {
+ // Trash: return empty (no trashed items tracked).
+ os_log_info(enumeratorLog(), "Enumerated 0 items for trash container");
+ [observer didEnumerateItems:@[]];
+ [observer finishEnumeratingWithError:nil];
+ return;
+ } else {
+ // Specific folder: find the folder's path by its fileId,
+ // then list items whose parentPath matches that path.
+ for (NSDictionary *item in allItems) {
+ if ([item[@"fileId"] isEqualToString:_containerId]) {
+ targetParentPath = item[@"path"];
+ break;
+ }
+ }
+ if (!targetParentPath) {
+ os_log_error(enumeratorLog(), "Container not found in metadata: %{public}@", _containerId);
+ [observer didEnumerateItems:@[]];
+ [observer finishEnumeratingWithError:nil];
+ return;
+ }
+ }
+
+ // Filter items by parent path.
+ NSMutableArray *providerItems = [NSMutableArray array];
+ for (NSDictionary *dict in allItems) {
+ if (targetParentPath == nil) {
+ // Working set: include all items.
+ } else if (![dict[@"parentPath"] isEqualToString:targetParentPath]) {
+ continue;
+ }
+
+ FileProviderItem *item = [[FileProviderItem alloc] initWithDictionary:dict];
+ [providerItems addObject:item];
+ }
+
+ os_log_info(enumeratorLog(), "Enumerated %lu items for container %{public}@",
+ (unsigned long)providerItems.count, _containerId);
+
+ appendTrace([NSString stringWithFormat:@"[%@] enumerateItems RESULT container=%@ items=%lu allItems=%lu\n",
+ [NSDate date], _containerId, (unsigned long)providerItems.count, (unsigned long)allItems.count]);
+
+ [observer didEnumerateItems:providerItems];
+ [observer finishEnumeratingWithError:nil];
+}
+
+- (void)enumerateChangesForObserver:(id)observer
+ fromSyncAnchor:(NSFileProviderSyncAnchor)anchor {
+ os_log_debug(enumeratorLog(), "enumerateChanges CALLED container=%{public}@", _containerId);
+
+ {
+ NSString *inAnchorStr = anchor ? [[NSString alloc] initWithData:anchor encoding:NSUTF8StringEncoding] : @"(nil)";
+ appendTrace([NSString stringWithFormat:@"[%@] enumerateChanges container=%@ anchor=%@\n",
+ [NSDate date], _containerId, inAnchorStr]);
+ }
+
+ os_log_info(enumeratorLog(), "enumerateChanges container=%{public}@", _containerId);
+
+ // Read current metadata to build a content-based sync anchor.
+ NSArray *allItems = readSharedMetadata(_domain);
+ NSString *currentAnchorString = @"empty";
+ if (allItems) {
+ // Use item count + latest modtime as a simple content anchor.
+ int64_t latestModtime = 0;
+ for (NSDictionary *dict in allItems) {
+ int64_t mt = [dict[@"modtime"] longLongValue];
+ if (mt > latestModtime) latestModtime = mt;
+ }
+ currentAnchorString = [NSString stringWithFormat:@"%lu-%lld",
+ (unsigned long)allItems.count, latestModtime];
+ }
+ NSData *currentAnchor = [currentAnchorString dataUsingEncoding:NSUTF8StringEncoding];
+
+ // Compare with incoming anchor. If different (or first call), report all items as updates.
+ NSString *incomingAnchorString = anchor ? [[NSString alloc] initWithData:anchor encoding:NSUTF8StringEncoding] : @"";
+
+ // Always report all items as updates. The system may have a cached anchor
+ // from a previous run where items were enumerated but not successfully stored
+ // (e.g. due to a crash). Reporting all items is idempotent — fileproviderd
+ // will reconcile against its database.
+ os_log_info(enumeratorLog(), "enumerateChanges: reporting all items (incoming=%{public}@ current=%{public}@)",
+ incomingAnchorString, currentAnchorString);
+
+ if (allItems) {
+ // Filter items for this container and report them as updates.
+ NSMutableArray *updatedItems = [NSMutableArray array];
+ NSMutableSet *currentFileIds = [NSMutableSet set];
+ NSString *targetParentPath = nil;
+
+ if ([_containerId isEqualToString:NSFileProviderRootContainerItemIdentifier]) {
+ targetParentPath = @"";
+ } else if ([_containerId isEqualToString:NSFileProviderWorkingSetContainerItemIdentifier]) {
+ targetParentPath = nil;
+ } else if ([_containerId isEqualToString:NSFileProviderTrashContainerItemIdentifier]) {
+ // Trash: no changes.
+ [observer finishEnumeratingChangesUpToSyncAnchor:currentAnchor moreComing:NO];
+ return;
+ } else {
+ for (NSDictionary *item in allItems) {
+ if ([item[@"fileId"] isEqualToString:_containerId]) {
+ targetParentPath = item[@"path"];
+ break;
+ }
+ }
+ if (!targetParentPath) {
+ os_log_error(enumeratorLog(), "enumerateChanges: container %{public}@ not found in metadata — skipping", _containerId);
+ [observer finishEnumeratingChangesUpToSyncAnchor:currentAnchor moreComing:NO];
+ return;
+ }
+ }
+
+ for (NSDictionary *dict in allItems) {
+ if (targetParentPath != nil && ![dict[@"parentPath"] isEqualToString:targetParentPath]) {
+ continue;
+ }
+ FileProviderItem *item = [[FileProviderItem alloc] initWithDictionary:dict];
+ [updatedItems addObject:item];
+ [currentFileIds addObject:dict[@"fileId"] ?: @""];
+ }
+
+ os_log_info(enumeratorLog(), "enumerateChanges: reporting %lu updated items for %{public}@",
+ (unsigned long)updatedItems.count, _containerId);
+
+ if (updatedItems.count > 0) {
+ [observer didUpdateItems:updatedItems];
+ }
+
+ // Detect deleted items by comparing current fileIds with the set from the
+ // previous enumerateChanges call. Report deletions so fileproviderd removes
+ // them from Finder.
+ // Cache key must include the domain identifier so that multiple
+ // domains (spaces/accounts) don't overwrite each other's caches.
+ NSString *cacheKey = [NSString stringWithFormat:@"prevFileIds_%@_%@",
+ _domain.identifier,
+ [_containerId stringByReplacingOccurrencesOfString:@"/" withString:@"_"]];
+ NSURL *containerURL = [[NSFileManager defaultManager]
+ containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier];
+ if (containerURL) {
+ NSURL *cacheURL = [containerURL URLByAppendingPathComponent:
+ [NSString stringWithFormat:@"%@.plist", cacheKey]];
+ NSArray *previousIds = [NSArray arrayWithContentsOfURL:cacheURL];
+ if (previousIds) {
+ NSMutableSet *previousSet = [NSMutableSet setWithArray:previousIds];
+ [previousSet minusSet:currentFileIds];
+ if (previousSet.count > 0) {
+ NSArray *deletedIds = [previousSet allObjects];
+ os_log_info(enumeratorLog(), "enumerateChanges: reporting %lu deleted items for %{public}@",
+ (unsigned long)deletedIds.count, _containerId);
+ [observer didDeleteItemsWithIdentifiers:deletedIds];
+ }
+ }
+ // Save current set for next comparison.
+ [[currentFileIds allObjects] writeToURL:cacheURL atomically:YES];
+ }
+ }
+
+ [observer finishEnumeratingChangesUpToSyncAnchor:currentAnchor moreComing:NO];
+}
+
+- (void)currentSyncAnchorWithCompletionHandler:(void (^)(NSFileProviderSyncAnchor _Nullable))completionHandler {
+ // Build a content-based anchor from the shared metadata.
+ NSArray *allItems = readSharedMetadata(_domain);
+ NSString *anchorString = @"empty";
+ if (allItems) {
+ int64_t latestModtime = 0;
+ for (NSDictionary *dict in allItems) {
+ int64_t mt = [dict[@"modtime"] longLongValue];
+ if (mt > latestModtime) latestModtime = mt;
+ }
+ anchorString = [NSString stringWithFormat:@"%lu-%lld",
+ (unsigned long)allItems.count, latestModtime];
+ }
+ NSData *anchorData = [anchorString dataUsingEncoding:NSUTF8StringEncoding];
+
+ os_log_info(enumeratorLog(), "currentSyncAnchor: %{public}@", anchorString);
+
+ completionHandler(anchorData);
+}
+
+- (void)invalidate {
+ os_log_info(enumeratorLog(), "Enumerator invalidated for container: %{public}@", _containerId);
+ _invalidated = YES;
+ _xpcService = nil;
+}
+
+@end
diff --git a/src/extensions/fileprovider/FileProviderItem.h b/src/extensions/fileprovider/FileProviderItem.h
new file mode 100644
index 0000000000..447b799a00
--- /dev/null
+++ b/src/extensions/fileprovider/FileProviderItem.h
@@ -0,0 +1,61 @@
+// FileProviderItem -- NSFileProviderItem adapter wrapping sync journal data
+// for the macOS File Provider extension.
+#pragma once
+
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// Objective-C class conforming to NSFileProviderItem that wraps sync journal
+/// data into the form expected by the macOS File Provider framework.
+///
+/// Since the extension runs in a separate process with no direct Qt access,
+/// all data is stored as Objective-C types (NSString, NSDate, NSNumber).
+API_AVAILABLE(macos(12.0))
+@interface FileProviderItem : NSObject
+
+#pragma mark - NSFileProviderItem required properties
+
+@property (nonatomic, readonly, copy) NSFileProviderItemIdentifier itemIdentifier;
+@property (nonatomic, readonly, copy) NSFileProviderItemIdentifier parentItemIdentifier;
+@property (nonatomic, readonly, copy) NSString *filename;
+@property (nonatomic, readonly, copy) NSString *typeIdentifier;
+@property (nonatomic, readonly, copy) UTType *contentType;
+@property (nonatomic, readonly) NSFileProviderItemCapabilities capabilities;
+@property (nonatomic, readonly, nullable) NSNumber *documentSize;
+@property (nonatomic, readonly, nullable) NSDate *contentModificationDate;
+@property (nonatomic, readonly, nullable) NSDate *creationDate;
+@property (nonatomic, readonly, nullable) NSNumber *childItemCount;
+@property (nonatomic, readonly) NSFileProviderItemVersion *itemVersion;
+
+#pragma mark - Transfer state properties
+
+@property (nonatomic, readonly) BOOL isUploaded;
+@property (nonatomic, readonly) BOOL isDownloaded;
+@property (nonatomic, readonly) BOOL isDownloading;
+@property (nonatomic, readonly) BOOL isUploading;
+
+#pragma mark - Directory properties
+
+#pragma mark - Initializers
+
+/// Designated initializer using a dictionary of metadata (typically received via XPC).
+/// Keys: @"fileId", @"filename", @"parentId", @"isDirectory", @"size", @"modDate",
+/// @"isUploaded", @"isDownloaded", @"isDownloading", @"isUploading", @"childItemCount"
+- (instancetype)initWithDictionary:(NSDictionary *)dict;
+
+/// Convenience initializer with explicit parameters.
+- (instancetype)initWithIdentifier:(NSString *)fileId
+ filename:(NSString *)name
+ parentIdentifier:(NSFileProviderItemIdentifier)parentId
+ isDirectory:(BOOL)isDir
+ size:(int64_t)size
+ modDate:(nullable NSDate *)date;
+
+/// Returns a placeholder root container item.
++ (instancetype)rootContainerItem;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/src/extensions/fileprovider/FileProviderItem.mm b/src/extensions/fileprovider/FileProviderItem.mm
new file mode 100644
index 0000000000..15020f76a0
--- /dev/null
+++ b/src/extensions/fileprovider/FileProviderItem.mm
@@ -0,0 +1,239 @@
+// FileProviderItem -- NSFileProviderItem adapter implementation.
+// Maps sync journal metadata to the NSFileProviderItem protocol for Finder integration.
+
+#import "FileProviderItem.h"
+
+#import
+#import
+
+static os_log_t itemLog(void) {
+ static os_log_t log = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ log = os_log_create("eu.opencloud.desktop.fileprovider", "item");
+ });
+ return log;
+}
+
+#pragma mark - UTI Helper
+
+/// Derives a UTI from a filename extension. Returns "public.data" as fallback for files,
+/// "public.folder" for directories.
+static NSString *utiForFilename(NSString *filename, BOOL isDirectory) {
+ if (isDirectory) {
+ return UTTypeFolder.identifier;
+ }
+
+ NSString *extension = filename.pathExtension;
+ if (extension.length > 0) {
+ UTType *type = [UTType typeWithFilenameExtension:extension];
+ if (type != nil) {
+ return type.identifier;
+ }
+ }
+
+ return UTTypeData.identifier;
+}
+
+#pragma mark - FileProviderItem
+
+API_AVAILABLE(macos(12.0))
+@implementation FileProviderItem {
+ NSFileProviderItemIdentifier _itemIdentifier;
+ NSFileProviderItemIdentifier _parentItemIdentifier;
+ NSString *_filename;
+ NSString *_typeIdentifier;
+ BOOL _isDirectory;
+ NSNumber *_documentSize;
+ NSDate *_contentModificationDate;
+ NSDate *_creationDate;
+ BOOL _isUploaded;
+ BOOL _isDownloaded;
+ BOOL _isDownloading;
+ BOOL _isUploading;
+ NSNumber *_childItemCount;
+ NSFileProviderItemVersion *_itemVersion;
+}
+
+#pragma mark - Initializers
+
+- (instancetype)initWithIdentifier:(NSString *)fileId
+ filename:(NSString *)name
+ parentIdentifier:(NSFileProviderItemIdentifier)parentId
+ isDirectory:(BOOL)isDir
+ size:(int64_t)size
+ modDate:(nullable NSDate *)date {
+ self = [super init];
+ if (self) {
+ _itemIdentifier = [fileId copy];
+ _parentItemIdentifier = [parentId copy];
+ _filename = [name copy];
+ _isDirectory = isDir;
+ _typeIdentifier = utiForFilename(name, isDir);
+ _documentSize = isDir ? nil : @(size);
+ _contentModificationDate = date;
+ _creationDate = date;
+ _isUploaded = YES;
+ _isDownloaded = NO;
+ _isDownloading = NO;
+ _isUploading = NO;
+ _childItemCount = nil;
+
+ // Build itemVersion from modification date (or a static seed if no date).
+ // NSFileProviderItemVersion is required for replicated extensions.
+ NSData *versionData;
+ if (date) {
+ int64_t epoch = (int64_t)[date timeIntervalSince1970];
+ versionData = [NSData dataWithBytes:&epoch length:sizeof(epoch)];
+ } else {
+ uint64_t seed = 1;
+ versionData = [NSData dataWithBytes:&seed length:sizeof(seed)];
+ }
+ _itemVersion = [[NSFileProviderItemVersion alloc] initWithContentVersion:versionData
+ metadataVersion:versionData];
+
+ os_log_debug(itemLog(), "Created FileProviderItem id=%{public}@ name=%{public}@ dir=%d",
+ fileId, name, isDir);
+ }
+ return self;
+}
+
+- (instancetype)initWithDictionary:(NSDictionary *)dict {
+ NSString *fileId = dict[@"fileId"] ?: @"";
+ // Accept both "filename" (old XPC format) and "name" (shared plist format).
+ NSString *filename = dict[@"filename"] ?: dict[@"name"] ?: @"";
+ NSString *parentId = dict[@"parentId"] ?: NSFileProviderRootContainerItemIdentifier;
+ BOOL isDirectory = [dict[@"isDirectory"] boolValue];
+ int64_t size = [dict[@"size"] longLongValue];
+
+ // Accept both NSDate "modDate" (old XPC format) and NSNumber "modtime" (shared plist, seconds since epoch).
+ NSDate *modDate = dict[@"modDate"];
+ if (!modDate && dict[@"modtime"]) {
+ NSTimeInterval seconds = [dict[@"modtime"] doubleValue];
+ if (seconds > 0) {
+ modDate = [NSDate dateWithTimeIntervalSince1970:seconds];
+ }
+ }
+
+ self = [self initWithIdentifier:fileId
+ filename:filename
+ parentIdentifier:parentId
+ isDirectory:isDirectory
+ size:size
+ modDate:modDate];
+ if (self) {
+ // Override transfer state from dictionary if present
+ if (dict[@"isUploaded"] != nil) {
+ _isUploaded = [dict[@"isUploaded"] boolValue];
+ }
+ if (dict[@"isDownloaded"] != nil) {
+ _isDownloaded = [dict[@"isDownloaded"] boolValue];
+ }
+ if (dict[@"isDownloading"] != nil) {
+ _isDownloading = [dict[@"isDownloading"] boolValue];
+ }
+ if (dict[@"isUploading"] != nil) {
+ _isUploading = [dict[@"isUploading"] boolValue];
+ }
+ if (dict[@"childItemCount"] != nil) {
+ _childItemCount = dict[@"childItemCount"];
+ }
+ }
+ return self;
+}
+
++ (instancetype)rootContainerItem {
+ FileProviderItem *root = [[FileProviderItem alloc]
+ initWithIdentifier:NSFileProviderRootContainerItemIdentifier
+ filename:@"OpenCloud"
+ parentIdentifier:NSFileProviderRootContainerItemIdentifier
+ isDirectory:YES
+ size:0
+ modDate:nil];
+ return root;
+}
+
+#pragma mark - NSFileProviderItem Properties
+
+- (NSFileProviderItemIdentifier)itemIdentifier {
+ return _itemIdentifier;
+}
+
+- (NSFileProviderItemIdentifier)parentItemIdentifier {
+ return _parentItemIdentifier;
+}
+
+- (NSString *)filename {
+ return _filename;
+}
+
+- (NSString *)typeIdentifier {
+ return _typeIdentifier;
+}
+
+- (NSFileProviderItemCapabilities)capabilities {
+ if (_isDirectory) {
+ return NSFileProviderItemCapabilitiesAllowsAll;
+ }
+
+ return NSFileProviderItemCapabilitiesAllowsReading
+ | NSFileProviderItemCapabilitiesAllowsWriting
+ | NSFileProviderItemCapabilitiesAllowsRenaming
+ | 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/FileProviderThumbnails.h b/src/extensions/fileprovider/FileProviderThumbnails.h
new file mode 100644
index 0000000000..de12175363
--- /dev/null
+++ b/src/extensions/fileprovider/FileProviderThumbnails.h
@@ -0,0 +1,35 @@
+// FileProviderThumbnails -- Helper class for fetching and caching thumbnails
+// served to the macOS File Provider framework via NSFileProviderThumbnailing.
+#pragma once
+
+#import
+#import
+
+@class FileProviderXPCService;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// Fetches and caches file thumbnails for the File Provider extension.
+/// Uses XPC to request thumbnail data from the main app, and maintains
+/// a two-tier cache (NSCache in-memory + disk in the app group container)
+/// with a 24-hour TTL.
+API_AVAILABLE(macos(12.0))
+@interface FileProviderThumbnails : NSObject
+
+/// Designated initializer.
+/// @param xpcService The XPC service used to request thumbnails from the main app.
+- (instancetype)initWithXPCService:(FileProviderXPCService *)xpcService;
+
+/// Fetch a thumbnail for a given file identifier.
+/// @param fileId The server-side file identifier.
+/// @param size The requested thumbnail dimensions.
+/// @param handler Called with thumbnail image data (PNG) or nil if unavailable.
+/// Error is non-nil only on infrastructure failures, not missing thumbnails.
+- (void)fetchThumbnail:(NSString *)fileId size:(CGSize)size completionHandler:(void (^)(NSData *_Nullable imageData, NSError *_Nullable error))handler;
+
+/// Remove all cached thumbnails (both in-memory and on-disk).
+- (void)clearCache;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/src/extensions/fileprovider/FileProviderThumbnails.mm b/src/extensions/fileprovider/FileProviderThumbnails.mm
new file mode 100644
index 0000000000..2282048ba2
--- /dev/null
+++ b/src/extensions/fileprovider/FileProviderThumbnails.mm
@@ -0,0 +1,204 @@
+// FileProviderThumbnails -- Thumbnail fetching and caching for the File Provider extension.
+// Two-tier cache: NSCache (in-memory) + disk cache in the app group container.
+
+#import "FileProviderThumbnails.h"
+#import "FileProviderXPCService.h"
+
+#import
+
+static os_log_t thumbnailLog(void) {
+ static os_log_t log = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ log = os_log_create("eu.opencloud.desktop.fileprovider", "thumbnails");
+ });
+ return log;
+}
+
+/// Cache TTL: 24 hours in seconds.
+static const NSTimeInterval kThumbnailCacheTTL = 24.0 * 60.0 * 60.0;
+
+/// Maximum number of thumbnails kept in the in-memory cache.
+static const NSUInteger kMemoryCacheCountLimit = 200;
+
+#pragma mark - FileProviderThumbnails
+
+API_AVAILABLE(macos(12.0))
+@implementation FileProviderThumbnails {
+ FileProviderXPCService *_xpcService;
+
+ /// In-memory cache keyed by "fileId-WxH".
+ NSCache *_memoryCache;
+
+ /// Serial queue protecting disk cache reads/writes.
+ dispatch_queue_t _cacheQueue;
+
+ /// Root directory for the on-disk thumbnail cache inside the app group container.
+ NSURL *_diskCacheURL;
+}
+
+- (instancetype)initWithXPCService:(FileProviderXPCService *)xpcService {
+ self = [super init];
+ if (self) {
+ _xpcService = xpcService;
+
+ _memoryCache = [[NSCache alloc] init];
+ _memoryCache.countLimit = kMemoryCacheCountLimit;
+
+ _cacheQueue = dispatch_queue_create("eu.opencloud.desktop.fileprovider.thumbnailcache",
+ DISPATCH_QUEUE_SERIAL);
+
+ // Use the app group container for shared disk cache.
+ NSURL *groupContainer = [[NSFileManager defaultManager]
+ containerURLForSecurityApplicationGroupIdentifier:@"group.eu.opencloud.desktop"];
+ if (groupContainer) {
+ _diskCacheURL = [groupContainer URLByAppendingPathComponent:@"ThumbnailCache"
+ isDirectory:YES];
+ } else {
+ // Fallback to temporary directory if app group is unavailable.
+ os_log_error(thumbnailLog(), "App group container unavailable, using temp dir for thumbnail cache");
+ _diskCacheURL = [NSURL fileURLWithPath:[NSTemporaryDirectory()
+ stringByAppendingPathComponent:@"OpenCloudThumbnailCache"]
+ isDirectory:YES];
+ }
+
+ // Ensure the cache directory exists.
+ [[NSFileManager defaultManager] createDirectoryAtURL:_diskCacheURL
+ withIntermediateDirectories:YES
+ attributes:nil
+ error:nil];
+ }
+ return self;
+}
+
+#pragma mark - Public
+
+- (void)fetchThumbnail:(NSString *)fileId
+ size:(CGSize)size
+ completionHandler:(void (^)(NSData * _Nullable, NSError * _Nullable))handler {
+
+ NSString *cacheKey = [self _cacheKeyForFileId:fileId size:size];
+
+ // 1. Check in-memory cache.
+ NSData *memoryCached = [_memoryCache objectForKey:cacheKey];
+ if (memoryCached) {
+ os_log_debug(thumbnailLog(), "Thumbnail cache hit (memory) for %{public}@", fileId);
+ handler(memoryCached, nil);
+ return;
+ }
+
+ // 2. Check disk cache (off main thread).
+ dispatch_async(_cacheQueue, ^{
+ NSData *diskCached = [self _readDiskCacheForKey:cacheKey];
+ if (diskCached) {
+ os_log_debug(thumbnailLog(), "Thumbnail cache hit (disk) for %{public}@", fileId);
+ [self->_memoryCache setObject:diskCached forKey:cacheKey];
+ handler(diskCached, nil);
+ return;
+ }
+
+ // 3. Fetch via XPC from the main app.
+ os_log_info(thumbnailLog(), "Fetching thumbnail via XPC for %{public}@ size=%.0fx%.0f",
+ fileId, size.width, size.height);
+
+ id proxy = self->_xpcService.remoteObjectProxy;
+ if (!proxy) {
+ os_log_error(thumbnailLog(), "No XPC proxy for thumbnail fetch of %{public}@", fileId);
+ handler(nil, nil);
+ return;
+ }
+
+ [proxy fetchThumbnail:fileId size:size completionHandler:^(NSData *imageData, NSError *error) {
+ if (error) {
+ os_log_error(thumbnailLog(), "XPC thumbnail error for %{public}@: %{public}@",
+ fileId, error.localizedDescription);
+ handler(nil, error);
+ return;
+ }
+
+ if (!imageData || imageData.length == 0) {
+ // No thumbnail available -- graceful degradation.
+ os_log_debug(thumbnailLog(), "No thumbnail available for %{public}@", fileId);
+ handler(nil, nil);
+ return;
+ }
+
+ // Cache the result.
+ [self->_memoryCache setObject:imageData forKey:cacheKey];
+ dispatch_async(self->_cacheQueue, ^{
+ [self _writeDiskCache:imageData forKey:cacheKey];
+ });
+
+ os_log_info(thumbnailLog(), "Thumbnail fetched and cached for %{public}@ (%lu bytes)",
+ fileId, (unsigned long)imageData.length);
+ handler(imageData, nil);
+ }];
+ });
+}
+
+- (void)clearCache {
+ [_memoryCache removeAllObjects];
+
+ dispatch_async(_cacheQueue, ^{
+ NSError *error = nil;
+ [[NSFileManager defaultManager] removeItemAtURL:self->_diskCacheURL error:&error];
+ if (error) {
+ os_log_error(thumbnailLog(), "Failed to clear disk cache: %{public}@",
+ error.localizedDescription);
+ }
+ [[NSFileManager defaultManager] createDirectoryAtURL:self->_diskCacheURL
+ withIntermediateDirectories:YES
+ attributes:nil
+ error:nil];
+ os_log_info(thumbnailLog(), "Thumbnail cache cleared");
+ });
+}
+
+#pragma mark - Private: Cache Key
+
+- (NSString *)_cacheKeyForFileId:(NSString *)fileId size:(CGSize)size {
+ return [NSString stringWithFormat:@"%@-%.0fx%.0f", fileId, size.width, size.height];
+}
+
+#pragma mark - Private: Disk Cache
+
+/// Returns the file URL for a given cache key inside the disk cache directory.
+- (NSURL *)_diskCacheFileURLForKey:(NSString *)key {
+ // Use a simple hash to avoid filesystem-unfriendly characters.
+ NSString *safeKey = [[key dataUsingEncoding:NSUTF8StringEncoding] base64EncodedStringWithOptions:0];
+ return [_diskCacheURL URLByAppendingPathComponent:safeKey];
+}
+
+/// Reads data from disk cache if it exists and has not expired (24h TTL).
+/// Must be called on _cacheQueue.
+- (NSData * _Nullable)_readDiskCacheForKey:(NSString *)key {
+ NSURL *fileURL = [self _diskCacheFileURLForKey:key];
+
+ NSError *error = nil;
+ NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:fileURL.path error:&error];
+ if (!attrs) {
+ return nil;
+ }
+
+ // Check TTL.
+ NSDate *modDate = attrs[NSFileModificationDate];
+ if (modDate && [[NSDate date] timeIntervalSinceDate:modDate] > kThumbnailCacheTTL) {
+ // Expired -- remove stale entry.
+ [[NSFileManager defaultManager] removeItemAtURL:fileURL error:nil];
+ return nil;
+ }
+
+ return [NSData dataWithContentsOfURL:fileURL options:0 error:&error];
+}
+
+/// Writes data to disk cache. Must be called on _cacheQueue.
+- (void)_writeDiskCache:(NSData *)data forKey:(NSString *)key {
+ NSURL *fileURL = [self _diskCacheFileURLForKey:key];
+ NSError *error = nil;
+ if (![data writeToURL:fileURL options:NSDataWritingAtomic error:&error]) {
+ os_log_error(thumbnailLog(), "Failed to write thumbnail to disk cache: %{public}@",
+ error.localizedDescription);
+ }
+}
+
+@end
diff --git a/src/extensions/fileprovider/FileProviderXPCService.h b/src/extensions/fileprovider/FileProviderXPCService.h
new file mode 100644
index 0000000000..1de96b55bc
--- /dev/null
+++ b/src/extensions/fileprovider/FileProviderXPCService.h
@@ -0,0 +1,144 @@
+// FileProviderXPCService -- XPC communication bridge between the File Provider
+// extension process and the main OpenCloud application process.
+#pragma once
+
+#import
+#import
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// Stable XPC service name shared between the extension and the main app.
+/// Both sides must agree on this identifier.
+static NSString *const kOpenCloudXPCServiceName = @"eu.opencloud.desktop.fileprovider.xpc";
+
+/// App Group identifier used to share data between the main app and extension.
+/// Set via -DAPP_GROUP_IDENTIFIER=... compile definition from CMake.
+#ifndef APP_GROUP_IDENTIFIER
+#define APP_GROUP_IDENTIFIER "eu.opencloud.desktop"
+#endif
+static NSString *const kOpenCloudAppGroupIdentifier = @APP_GROUP_IDENTIFIER;
+
+/// Filename for the XPC listener endpoint stored in the App Group shared container.
+/// The main app writes this file; the extension reads it to establish the XPC connection.
+static NSString *const kOpenCloudXPCEndpointFilename = @"xpc_listener_endpoint.data";
+
+#pragma mark - XPC Protocol
+
+/// Protocol defining the messages the File Provider Extension sends to the main
+/// OpenCloud application via XPC. The main app must vend an object conforming
+/// to this protocol on its NSXPCListener.
+///
+/// All methods are asynchronous and use completion handlers to return results
+/// back to the extension process.
+@protocol OpenCloudXPCServiceProtocol
+
+/// Request the main app to hydrate (download) a file's contents.
+/// @param fileId The server-side file identifier.
+/// @param url The local URL where the content should be written.
+/// @param handler Called when hydration completes; error is nil on success.
+- (void)requestHydration:(NSString *)fileId targetURL:(NSURL *)url completionHandler:(void (^)(NSError *_Nullable error))handler;
+
+/// Schedule an upload of a locally-created or modified file to the server.
+/// @param localURL The local file URL containing the content to upload.
+/// @param parentId The server-side identifier of the parent folder.
+/// @param handler Called with the server-assigned file ID on success, or error.
+- (void)scheduleUpload:(NSURL *)localURL
+ parentIdentifier:(NSString *)parentId
+ completionHandler:(void (^)(NSString *_Nullable serverFileId, NSError *_Nullable error))handler;
+
+/// Query the current pin state for a file.
+/// @param fileId The server-side file identifier.
+/// @param handler Called with the pin state (as NSInteger) or error.
+- (void)requestPinState:(NSString *)fileId completionHandler:(void (^)(NSInteger pinState, NSError *_Nullable error))handler;
+
+/// Set the pin state for a file (e.g., always keep downloaded, or free space).
+/// @param pinState The desired pin state (as NSInteger).
+/// @param fileId The server-side file identifier.
+/// @param handler Called when the operation completes; error is nil on success.
+- (void)setPinState:(NSInteger)pinState forFileId:(NSString *)fileId completionHandler:(void (^)(NSError *_Nullable error))handler;
+
+/// Connectivity check. Returns YES if the main app is alive and responding.
+/// @param handler Called with the liveness status.
+- (void)ping:(void (^)(BOOL alive))handler;
+
+/// Enumerate child items of a container (folder) from the sync journal.
+/// @param containerId The file ID of the parent container, or root identifier.
+/// @param cursor Opaque pagination cursor (empty string for first page).
+/// @param handler Called with an array of item dictionaries, an optional next cursor
+/// (nil if no more pages), or an error.
+- (void)enumerateItems:(NSString *)containerId
+ cursor:(NSString *)cursor
+ completionHandler:(void (^)(NSArray *_Nullable items, NSString *_Nullable nextCursor, NSError *_Nullable error))handler;
+
+/// Fetch metadata for a single item by its server-side file identifier.
+/// @param identifier The file ID to look up.
+/// @param handler Called with item metadata dictionary or error.
+- (void)itemForIdentifier:(NSString *)identifier completionHandler:(void (^)(NSDictionary *_Nullable itemDict, NSError *_Nullable error))handler;
+
+/// Create a directory on the server.
+/// @param name The directory name.
+/// @param parentId The server-side identifier of the parent folder.
+/// @param handler Called with metadata dictionary of the created directory, or error.
+- (void)createDirectory:(NSString *)name
+ parentIdentifier:(NSString *)parentId
+ completionHandler:(void (^)(NSDictionary *_Nullable itemDict, NSError *_Nullable error))handler;
+
+/// Rename an item on the server.
+/// @param fileId The server-side file identifier.
+/// @param newName The new filename.
+/// @param handler Called with updated metadata dictionary, or error.
+- (void)renameItem:(NSString *)fileId
+ newName:(NSString *)newName
+ completionHandler:(void (^)(NSDictionary *_Nullable itemDict, NSError *_Nullable error))handler;
+
+/// Move an item to a different parent folder on the server.
+/// @param fileId The server-side file identifier.
+/// @param newParentId The server-side identifier of the new parent folder.
+/// @param handler Called with updated metadata dictionary, or error.
+- (void)moveItem:(NSString *)fileId
+ newParent:(NSString *)newParentId
+ completionHandler:(void (^)(NSDictionary *_Nullable itemDict, NSError *_Nullable error))handler;
+
+/// Delete an item from the server.
+/// @param fileId The server-side file identifier.
+/// @param handler Called with nil on success, or error.
+- (void)deleteItem:(NSString *)fileId completionHandler:(void (^)(NSError *_Nullable error))handler;
+
+/// Fetch a thumbnail image for a file.
+/// @param fileId The server-side file identifier.
+/// @param size The requested thumbnail dimensions.
+/// @param handler Called with PNG image data, or nil if no thumbnail is available.
+- (void)fetchThumbnail:(NSString *)fileId size:(CGSize)size completionHandler:(void (^)(NSData *_Nullable imageData, NSError *_Nullable error))handler;
+
+@end
+
+#pragma mark - XPC Service Source
+
+/// Implements NSFileProviderServiceSource to provide XPC connectivity between
+/// the File Provider extension and the main OpenCloud app.
+///
+/// The extension registers this service source so the system can broker
+/// connections. The main app's NSXPCListener vends an object conforming to
+/// OpenCloudXPCServiceProtocol.
+API_AVAILABLE(macos(12.0))
+@interface FileProviderXPCService : NSObject
+
+/// The stable service name used to identify this XPC service.
+@property (nonatomic, readonly, copy) NSFileProviderServiceName serviceName;
+
+/// Returns a proxy object conforming to OpenCloudXPCServiceProtocol for
+/// sending messages to the main application. May return nil if the
+/// connection has not been established.
+@property (nonatomic, readonly, nullable) id remoteObjectProxy;
+
+/// Designated initializer.
+- (instancetype)init;
+
+/// Explicitly invalidate the XPC connection. Called during extension teardown.
+- (void)invalidate;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/src/extensions/fileprovider/FileProviderXPCService.mm b/src/extensions/fileprovider/FileProviderXPCService.mm
new file mode 100644
index 0000000000..7fc2b5b199
--- /dev/null
+++ b/src/extensions/fileprovider/FileProviderXPCService.mm
@@ -0,0 +1,183 @@
+// FileProviderXPCService -- XPC communication bridge implementation.
+// Manages the NSXPCConnection lifecycle between extension and main app.
+
+#import "FileProviderXPCService.h"
+
+static os_log_t xpcLog(void) {
+ static os_log_t log = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ log = os_log_create("eu.opencloud.desktop.fileprovider", "xpc");
+ });
+ return log;
+}
+
+/// Maximum number of automatic reconnection attempts before giving up.
+static const NSUInteger MAX_RECONNECT_ATTEMPTS = 3;
+
+/// Delay between reconnection attempts (in seconds).
+static const NSTimeInterval RECONNECT_DELAY = 2.0;
+
+#pragma mark - FileProviderXPCService
+
+API_AVAILABLE(macos(12.0))
+@implementation FileProviderXPCService {
+ NSXPCConnection *_connection;
+ NSUInteger _reconnectAttempts;
+ BOOL _invalidated;
+}
+
+- (instancetype)init {
+ self = [super init];
+ if (self) {
+ _reconnectAttempts = 0;
+ _invalidated = NO;
+ // Connection is established lazily on first remoteObjectProxy call.
+ // Enumeration uses the shared plist and does not need XPC.
+ }
+ return self;
+}
+
+#pragma mark - NSFileProviderServiceSource
+
+- (NSFileProviderServiceName)serviceName {
+ return kOpenCloudXPCServiceName;
+}
+
+- (nullable NSXPCListenerEndpoint *)makeListenerEndpointAndReturnError:(NSError *__autoreleasing *)error {
+ // This method is called by the system when the main app wants to connect
+ // to this extension's service. For the extension-to-app direction, we
+ // use the connection created in _establishConnection instead.
+ //
+ // Return nil here; the actual communication channel is set up via
+ // NSXPCConnection to the main app's Mach service.
+ os_log_info(xpcLog(), "makeListenerEndpointAndReturnError called");
+
+ if (error) {
+ *error = [NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorServerUnreachable
+ userInfo:@{NSLocalizedDescriptionKey: @"Service endpoint not available from extension side"}];
+ }
+ return nil;
+}
+
+#pragma mark - Connection Management
+
+- (void)_establishConnection {
+ if (_invalidated) {
+ os_log_info(xpcLog(), "Connection not established: service has been invalidated");
+ return;
+ }
+
+ // Read the listener endpoint from the App Group shared container.
+ // The main app writes this file when it starts its anonymous NSXPCListener.
+ NSURL *containerURL = [[NSFileManager defaultManager]
+ containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier];
+ if (!containerURL) {
+ os_log_error(xpcLog(), "Cannot access App Group container: %{public}@", kOpenCloudAppGroupIdentifier);
+ [self _handleConnectionFailure];
+ return;
+ }
+
+ NSURL *endpointURL = [containerURL URLByAppendingPathComponent:kOpenCloudXPCEndpointFilename];
+ NSData *endpointData = [NSData dataWithContentsOfURL:endpointURL];
+ if (!endpointData) {
+ os_log_error(xpcLog(), "XPC endpoint file not found at: %{public}@ (main app may not be running)",
+ endpointURL.path);
+ [self _handleConnectionFailure];
+ return;
+ }
+
+ NSError *unarchiveError = nil;
+ NSXPCListenerEndpoint *endpoint = [NSKeyedUnarchiver unarchivedObjectOfClass:[NSXPCListenerEndpoint class]
+ fromData:endpointData
+ error:&unarchiveError];
+ if (!endpoint || unarchiveError) {
+ os_log_error(xpcLog(), "Failed to unarchive XPC endpoint: %{public}@",
+ unarchiveError.localizedDescription);
+ [self _handleConnectionFailure];
+ return;
+ }
+
+ os_log_info(xpcLog(), "Read XPC listener endpoint from App Group container");
+
+ _connection = [[NSXPCConnection alloc] initWithListenerEndpoint:endpoint];
+
+ // Configure the remote interface (what we expect the main app to implement).
+ _connection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(OpenCloudXPCServiceProtocol)];
+
+ __weak __typeof__(self) weakSelf = self;
+
+ _connection.interruptionHandler = ^{
+ os_log_error(xpcLog(), "XPC connection interrupted");
+ [weakSelf _handleConnectionFailure];
+ };
+
+ _connection.invalidationHandler = ^{
+ os_log_error(xpcLog(), "XPC connection invalidated");
+ [weakSelf _handleConnectionFailure];
+ };
+
+ [_connection resume];
+ _reconnectAttempts = 0;
+
+ os_log_info(xpcLog(), "XPC connection established via App Group endpoint");
+}
+
+- (void)_handleConnectionFailure {
+ if (_invalidated) {
+ return;
+ }
+
+ _connection = nil;
+
+ if (_reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
+ os_log_error(xpcLog(), "Max reconnection attempts (%lu) reached, giving up", (unsigned long)MAX_RECONNECT_ATTEMPTS);
+ return;
+ }
+
+ _reconnectAttempts++;
+ os_log_info(xpcLog(), "Scheduling reconnection attempt %lu/%lu in %.0f seconds",
+ (unsigned long)_reconnectAttempts,
+ (unsigned long)MAX_RECONNECT_ATTEMPTS,
+ RECONNECT_DELAY);
+
+ __weak __typeof__(self) weakSelf = self;
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(RECONNECT_DELAY * NSEC_PER_SEC)),
+ dispatch_get_main_queue(), ^{
+ [weakSelf _establishConnection];
+ });
+}
+
+#pragma mark - Remote Object Access
+
+- (nullable id)remoteObjectProxy {
+ if (!_connection) {
+ // Lazily establish the connection on first use.
+ os_log_info(xpcLog(), "remoteObjectProxy: establishing connection on demand");
+ [self _establishConnection];
+ }
+
+ if (!_connection) {
+ os_log_error(xpcLog(), "remoteObjectProxy: no connection available after attempt");
+ return nil;
+ }
+
+ return (id)[_connection remoteObjectProxyWithErrorHandler:^(NSError *error) {
+ os_log_error(xpcLog(), "Remote object proxy error: %{public}@", error.localizedDescription);
+ }];
+}
+
+#pragma mark - Teardown
+
+- (void)invalidate {
+ os_log_info(xpcLog(), "Invalidating XPC service");
+ _invalidated = YES;
+
+ if (_connection) {
+ [_connection invalidate];
+ _connection = nil;
+ }
+}
+
+@end
diff --git a/src/extensions/fileprovider/Info.plist.in b/src/extensions/fileprovider/Info.plist.in
new file mode 100644
index 0000000000..d275c78f2a
--- /dev/null
+++ b/src/extensions/fileprovider/Info.plist.in
@@ -0,0 +1,39 @@
+
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ OpenCloud File Provider
+ CFBundleExecutable
+ OpenCloudFileProviderExtension
+ CFBundleIdentifier
+ ${MACOSX_BUNDLE_GUI_IDENTIFIER}
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ ${MACOSX_BUNDLE_BUNDLE_NAME}
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ ${MACOSX_BUNDLE_SHORT_VERSION_STRING}
+ CFBundleVersion
+ ${MACOSX_BUNDLE_BUNDLE_VERSION}
+ NSExtension
+
+ NSExtensionFileProviderDocumentGroup
+ ${APPLE_DEVELOPMENT_TEAM}.${APPLICATION_REV_DOMAIN}
+ NSExtensionFileProviderSupportsEnumeration
+
+ NSExtensionPointIdentifier
+ com.apple.fileprovider-nonui
+ NSExtensionPrincipalClass
+ OpenCloudFileProviderExtension
+
+
+
diff --git a/src/extensions/fileprovider/OpenCloudFileProvider.entitlements b/src/extensions/fileprovider/OpenCloudFileProvider.entitlements
new file mode 100644
index 0000000000..a34c429901
--- /dev/null
+++ b/src/extensions/fileprovider/OpenCloudFileProvider.entitlements
@@ -0,0 +1,14 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.application-groups
+
+ @APP_GROUP_IDENTIFIER@
+
+ com.apple.security.network.client
+
+
+
diff --git a/src/extensions/fileprovider/OpenCloudFileProvider.entitlements.in b/src/extensions/fileprovider/OpenCloudFileProvider.entitlements.in
new file mode 100644
index 0000000000..ea8c34f0f0
--- /dev/null
+++ b/src/extensions/fileprovider/OpenCloudFileProvider.entitlements.in
@@ -0,0 +1,21 @@
+
+
+
+
+
+ com.apple.security.application-groups
+
+ ${APPLE_DEVELOPMENT_TEAM}.${APPLICATION_REV_DOMAIN}
+
+ com.apple.developer.fileprovider.server-capability
+
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)${APPLICATION_REV_DOMAIN}
+
+
+
diff --git a/src/extensions/fileprovider/OpenCloudFileProviderExtension.h b/src/extensions/fileprovider/OpenCloudFileProviderExtension.h
new file mode 100644
index 0000000000..6e88dfc7b7
--- /dev/null
+++ b/src/extensions/fileprovider/OpenCloudFileProviderExtension.h
@@ -0,0 +1,22 @@
+// OpenCloudFileProviderExtension -- NSFileProviderReplicatedExtension implementation
+// for macOS Files On Demand. Runs in an isolated extension process.
+#pragma once
+
+#import
+#import
+#import
+
+/// The principal class for the OpenCloud File Provider App Extension.
+/// Implements NSFileProviderReplicatedExtension (and NSFileProviderEnumerating)
+/// to integrate with the macOS Files On Demand system.
+///
+/// All protocol methods are currently stubbed and return appropriate
+/// "not implemented" errors while calling their completion handlers
+/// to prevent deadlocks.
+API_AVAILABLE(macos(12.0))
+@interface OpenCloudFileProviderExtension : NSObject
+
+/// The file provider domain this extension instance serves.
+@property (nonatomic, readonly, strong) NSFileProviderDomain *domain;
+
+@end
diff --git a/src/extensions/fileprovider/OpenCloudFileProviderExtension.mm b/src/extensions/fileprovider/OpenCloudFileProviderExtension.mm
new file mode 100644
index 0000000000..502e3bf2ab
--- /dev/null
+++ b/src/extensions/fileprovider/OpenCloudFileProviderExtension.mm
@@ -0,0 +1,1270 @@
+// OpenCloudFileProviderExtension -- NSFileProviderReplicatedExtension implementation.
+// Runs as a separate process managed by the macOS File Provider framework.
+
+#import "OpenCloudFileProviderExtension.h"
+
+#import "FileProviderEnumerator.h"
+#import "FileProviderItem.h"
+#import "FileProviderThumbnails.h"
+#import "FileProviderXPCService.h"
+
+static os_log_t extensionLog(void) {
+ static os_log_t log = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ log = os_log_create("eu.opencloud.desktop.fileprovider", "extension");
+ });
+ return log;
+}
+
+/// Appends a trace line to the debug log file in the App Group container.
+static NSString *traceLogPath(void) {
+ // Try App Group container first
+ NSURL *container = [[NSFileManager defaultManager]
+ containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier];
+ if (container) {
+ return [[container URLByAppendingPathComponent:@"fp_debug.log"] path];
+ }
+ // Fallback to sandbox temp dir
+ return [NSTemporaryDirectory() stringByAppendingPathComponent:@"fp_debug.log"];
+}
+
+static void appendTrace(NSString *line) {
+ NSString *path = traceLogPath();
+ NSFileHandle *fh = [NSFileHandle fileHandleForWritingAtPath:path];
+ if (!fh) {
+ [[NSFileManager defaultManager] createFileAtPath:path contents:nil attributes:nil];
+ fh = [NSFileHandle fileHandleForWritingAtPath:path];
+ }
+ if (fh) {
+ [fh seekToEndOfFile];
+ [fh writeData:[line dataUsingEncoding:NSUTF8StringEncoding]];
+ [fh closeFile];
+ }
+}
+
+/// Creates an NSError in the file provider extension domain for "not implemented" stubs.
+static NSError *notImplementedError(void) {
+ return [NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorServerUnreachable
+ userInfo:@{NSLocalizedDescriptionKey: @"Not yet implemented"}];
+}
+
+/// Creates an NSError indicating the user is not authenticated.
+static NSError *notAuthenticatedError(void) {
+ return [NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorNotAuthenticated
+ userInfo:@{NSLocalizedDescriptionKey:
+ NSLocalizedString(@"Bitte melde dich in der OpenCloud App an, um auf deine Dateien zugreifen zu können.",
+ @"FileProvider auth error")}];
+}
+
+/// Creates a user-visible error for configuration/connectivity issues.
+static NSError *configUnavailableError(NSString *detail) {
+ NSString *message = [NSString stringWithFormat:
+ NSLocalizedString(@"Die OpenCloud App muss gestartet und angemeldet sein, um Dateien herunterladen zu können. (%@)",
+ @"FileProvider config error"), detail];
+ return [NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorServerUnreachable
+ userInfo:@{NSLocalizedDescriptionKey: message}];
+}
+
+// Keep old name as alias for backward compatibility in non-download code paths.
+static NSError *xpcUnavailableError(void) {
+ return configUnavailableError(@"Hintergrund-Synchronisation nicht verfügbar");
+}
+
+/// 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;
+}
+
+#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];
+ _thumbnails = [[FileProviderThumbnails alloc] initWithXPCService:_xpcService];
+ os_log_info(extensionLog(), "Extension initialized for domain: %{public}@", domain.identifier);
+ }
+ return self;
+}
+
+- (void)invalidate {
+ os_log_info(extensionLog(), "Extension invalidated for domain: %{public}@", _domain.identifier);
+ [_xpcService invalidate];
+ _xpcService = nil;
+}
+
+#pragma mark - NSFileProviderReplicatedExtension (Item Lookup)
+
+- (NSProgress *)itemForIdentifier:(NSFileProviderItemIdentifier)identifier
+ request:(NSFileProviderRequest *)request
+ completionHandler:(void (^)(NSFileProviderItem _Nullable, NSError * _Nullable))completionHandler {
+ os_log_info(extensionLog(), "itemForIdentifier: %{public}@", identifier);
+
+ // Root container and trash are always resolvable without data.
+ if ([identifier isEqualToString:NSFileProviderRootContainerItemIdentifier]) {
+ completionHandler([FileProviderItem rootContainerItem], nil);
+ return [NSProgress discreteProgressWithTotalUnitCount:0];
+ }
+ if ([identifier isEqualToString:NSFileProviderTrashContainerItemIdentifier]) {
+ FileProviderItem *trashItem = [[FileProviderItem alloc]
+ initWithIdentifier:NSFileProviderTrashContainerItemIdentifier
+ filename:@".Trash"
+ parentIdentifier:NSFileProviderRootContainerItemIdentifier
+ isDirectory:YES
+ size:0
+ modDate:nil];
+ completionHandler(trashItem, nil);
+ return [NSProgress discreteProgressWithTotalUnitCount:0];
+ }
+
+ // Look up the item from the shared metadata plist in the App Group container.
+ NSURL *containerURL = [[NSFileManager defaultManager]
+ containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier];
+ if (containerURL) {
+ NSURL *metadataURL = [containerURL URLByAppendingPathComponent: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;
+ }
+
+ // Look up the file path from the items plist.
+ NSURL *metadataURL = [containerURL URLByAppendingPathComponent:itemsPlistName(self->_domain, containerURL)];
+ NSData *metaData = [NSData dataWithContentsOfURL:metadataURL];
+ NSString *filePath = nil;
+ NSDictionary *itemDict = nil;
+ if (metaData) {
+ NSArray *items = [NSPropertyListSerialization propertyListWithData:metaData
+ options:NSPropertyListImmutable
+ format:nil error:nil];
+ for (NSDictionary *item in items) {
+ if ([item[@"fileId"] isEqualToString:fileId]) {
+ filePath = item[@"path"];
+ itemDict = item;
+ break;
+ }
+ }
+ }
+ if (!filePath) {
+ os_log_error(extensionLog(), "fetchContents: fileId %{public}@ not found in items plist", fileId);
+ [self _completeHydrationForFileId:fileId url:nil item:nil
+ error:[NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorNoSuchItem
+ userInfo:@{NSLocalizedDescriptionKey:
+ NSLocalizedString(@"Diese Datei wurde nicht gefunden. Möglicherweise wurde sie verschoben oder gelöscht.",
+ @"FileProvider item not found")}]];
+ return;
+ }
+
+ // 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\n",
+ [NSDate date], templateName, templateParent, (unsigned long)options]);
+
+ 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 *newFileId = [http.allHeaderFields[@"OC-FileId"] copy]
+ ?: [NSString stringWithFormat:@"%@!%@", parentId, [[NSUUID UUID] UUIDString]];
+
+ NSString *dirPath = parentPath.length > 0
+ ? [NSString stringWithFormat:@"%@/%@", parentPath, filename]
+ : filename;
+
+ NSDictionary *dirDict = @{
+ @"fileId": newFileId,
+ @"filename": filename,
+ @"path": dirPath,
+ @"parentId": parentId,
+ @"parentPath": parentPath,
+ @"isDirectory": @YES,
+ @"size": @0,
+ @"modtime": @((int64_t)[[NSDate date] timeIntervalSince1970]),
+ @"etag": @"",
+ @"isVirtualFile": @NO,
+ @"isDownloaded": @YES,
+ @"davUrl": davUrl,
+ };
+
+ FileProviderItem *createdItem = [[FileProviderItem alloc] initWithDictionary:dirDict];
+ os_log_info(extensionLog(), "createItem: directory created id=%{public}@", newFileId);
+
+ [self _appendItemToPlist:dirDict];
+
+ progress.completedUnitCount = 100;
+ completionHandler(createdItem, NSFileProviderItemFields(0), NO, nil);
+ }] resume];
+
+ } else {
+ // --- File upload via PUT ---
+
+ // Stage the content to a temporary file before the async upload.
+ // The system-provided content URL may become invalid after this
+ // method returns, so we must copy it synchronously.
+ NSString *stagingDir = NSTemporaryDirectory();
+ NSString *stagingFilename = [NSString stringWithFormat:@"upload-%@-%@",
+ filename, [[NSUUID UUID] UUIDString]];
+ NSURL *stagingURL = [NSURL fileURLWithPath:[stagingDir stringByAppendingPathComponent:stagingFilename]];
+
+ NSError *copyError = nil;
+ [[NSFileManager defaultManager] copyItemAtURL:url toURL:stagingURL error:©Error];
+ if (copyError) {
+ os_log_error(extensionLog(), "createItem: failed to stage content: %{public}@",
+ copyError.localizedDescription);
+ completionHandler(nil, 0, NO, copyError);
+ return progress;
+ }
+
+ NSString *filePath = parentPath.length > 0
+ ? [NSString stringWithFormat:@"%@/%@", parentPath, filename]
+ : filename;
+ 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 *newFileId = [http.allHeaderFields[@"OC-FileId"] copy]
+ ?: [NSString stringWithFormat:@"%@!%@", parentId, [[NSUUID UUID] UUIDString]];
+ int64_t fileSize = stagedFileSize;
+
+ NSMutableDictionary *itemDict = [@{
+ @"fileId": newFileId,
+ @"filename": filename,
+ @"path": filePath,
+ @"parentId": parentId,
+ @"parentPath": parentPath,
+ @"isDirectory": @NO,
+ @"size": @(fileSize),
+ @"modtime": @((int64_t)[[NSDate date] timeIntervalSince1970]),
+ @"etag": [http.allHeaderFields[@"ETag"] copy] ?: @"",
+ @"isVirtualFile": @NO,
+ @"isDownloaded": @YES,
+ @"davUrl": davUrl,
+ } mutableCopy];
+
+ FileProviderItem *createdItem = [[FileProviderItem alloc] initWithDictionary:itemDict];
+ os_log_info(extensionLog(), "createItem: uploaded %{public}@ id=%{public}@", filename, newFileId);
+
+ // Persist the new item in the shared plist so subsequent
+ // enumerations include it. Without this, the enumerator
+ // would not report the item, and fileproviderd would
+ // eventually remove it from Finder.
+ [self _appendItemToPlist:itemDict];
+
+ progress.completedUnitCount = 100;
+ completionHandler(createdItem, NSFileProviderItemFields(0), NO, nil);
+ }] resume];
+ }
+
+ return progress;
+ }
+
+}
+
+#pragma mark - NSFileProviderReplicatedExtension (Modify)
+
+- (NSProgress *)modifyItem:(id)item
+ baseVersion:(NSFileProviderItemVersion *)version
+ changedFields:(NSFileProviderItemFields)changedFields
+ contents:(NSURL *)newContents
+ options:(NSFileProviderModifyItemOptions)options
+ request:(NSFileProviderRequest *)request
+ completionHandler:(void (^)(NSFileProviderItem _Nullable,
+ NSFileProviderItemFields,
+ BOOL,
+ NSError * _Nullable))completionHandler {
+ os_log_info(extensionLog(), "modifyItem: %{public}@ changedFields=0x%lx",
+ item.filename, (unsigned long)changedFields);
+
+ NSProgress *progress = [NSProgress discreteProgressWithTotalUnitCount:100];
+
+ NSString *fileId = [item.itemIdentifier copy];
+
+ // 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;
+ }
+
+ // For remaining operations (rename, content update), check XPC availability.
+ id proxy = _xpcService.remoteObjectProxy;
+
+ // Handle rename via XPC.
+ if (changedFields & NSFileProviderItemFilename) {
+ if (!proxy) {
+ os_log_error(extensionLog(), "modifyItem: no XPC proxy for rename of %{public}@", fileId);
+ completionHandler(nil, 0, NO, xpcUnavailableError());
+ return progress;
+ }
+ NSString *newName = [item.filename copy];
+ os_log_info(extensionLog(), "modifyItem: renaming %{public}@ to '%{public}@'", fileId, newName);
+
+ [proxy renameItem:fileId newName:newName completionHandler:^(NSDictionary *itemDict, NSError *error) {
+ if (error) {
+ os_log_error(extensionLog(), "modifyItem: rename failed: %{public}@",
+ error.localizedDescription);
+ completionHandler(nil, 0, NO, error);
+ return;
+ }
+
+ FileProviderItem *updatedItem = [[FileProviderItem alloc] initWithDictionary:itemDict];
+ os_log_info(extensionLog(), "modifyItem: rename succeeded for %{public}@", fileId);
+ progress.completedUnitCount = 100;
+ completionHandler(updatedItem, NSFileProviderItemFields(0), NO, nil);
+ }];
+ return progress;
+ }
+
+ // Handle content update (re-upload via XPC).
+ if (changedFields & NSFileProviderItemContents) {
+ if (!proxy) {
+ os_log_error(extensionLog(), "modifyItem: no XPC proxy for content update of %{public}@", fileId);
+ completionHandler(nil, 0, NO, xpcUnavailableError());
+ return progress;
+ }
+ if (!newContents) {
+ os_log_error(extensionLog(), "modifyItem: content change flagged but no content URL for %{public}@",
+ fileId);
+ 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
+ xpcService:_xpcService
+ domain:_domain];
+ return enumerator;
+ }
+
+ os_log_error(extensionLog(), "enumeratorForContainerItemIdentifier: unsupported container %{public}@",
+ containerItemIdentifier);
+ if (error) {
+ *error = [NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorNoSuchItem
+ userInfo:@{NSLocalizedDescriptionKey: @"Unsupported container identifier"}];
+ }
+ return nil;
+}
+
+#pragma mark - NSFileProviderThumbnailing
+
+- (NSProgress *)fetchThumbnailsForItemIdentifiers:(NSArray *)itemIdentifiers
+ requestedSize:(CGSize)size
+ perThumbnailCompletionHandler:(void (^)(NSFileProviderItemIdentifier,
+ NSData * _Nullable,
+ NSError * _Nullable))perThumbnailHandler
+ completionHandler:(void (^)(NSError * _Nullable))completionHandler {
+ os_log_info(extensionLog(), "fetchThumbnails: requested for %lu items at %.0fx%.0f",
+ (unsigned long)itemIdentifiers.count, size.width, size.height);
+
+ NSProgress *progress = [NSProgress progressWithTotalUnitCount:(int64_t)itemIdentifiers.count];
+
+ dispatch_group_t group = dispatch_group_create();
+
+ for (NSFileProviderItemIdentifier identifier in itemIdentifiers) {
+ dispatch_group_enter(group);
+
+ [_thumbnails fetchThumbnail:identifier size:size completionHandler:^(NSData *imageData, NSError *error) {
+ perThumbnailHandler(identifier, imageData, error);
+ progress.completedUnitCount += 1;
+ dispatch_group_leave(group);
+ }];
+ }
+
+ dispatch_group_notify(group, dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{
+ os_log_info(extensionLog(), "fetchThumbnails: completed for %lu items",
+ (unsigned long)itemIdentifiers.count);
+ completionHandler(nil);
+ });
+
+ return progress;
+}
+
+@end
diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt
index 6f53259813..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/libsync/common/syncjournaldb.cpp b/src/libsync/common/syncjournaldb.cpp
index 31c1350d53..57f7fc5c26 100644
--- a/src/libsync/common/syncjournaldb.cpp
+++ b/src/libsync/common/syncjournaldb.cpp
@@ -36,6 +36,10 @@
#include
+#ifdef Q_OS_MAC
+#import
+#endif
+
using namespace Qt::Literals::StringLiterals;
Q_LOGGING_CATEGORY(lcDb, "sync.database", QtInfoMsg)
@@ -827,6 +831,10 @@ bool SyncJournalDb::deleteFileRecord(const QString &filename, bool recursively)
{
QMutexLocker locker(&_mutex);
+#ifdef Q_OS_MAC
+ os_log_fault(OS_LOG_DEFAULT, "deleteFileRecord: %{public}s recursive=%d", qPrintable(filename), recursively);
+#endif
+
if (checkConnect()) {
// if (!recursively) {
// always delete the actual file.
diff --git a/src/libsync/creds/httpcredentials.h b/src/libsync/creds/httpcredentials.h
index 48a103a1db..98442f8596 100644
--- a/src/libsync/creds/httpcredentials.h
+++ b/src/libsync/creds/httpcredentials.h
@@ -64,6 +64,8 @@ class OPENCLOUD_SYNC_EXPORT HttpCredentials : public AbstractCredentials
*/
bool refreshAccessToken();
+ /// Returns the current Bearer access token (empty if not authenticated).
+ QString accessToken() const { return _accessToken; }
protected:
HttpCredentials() = default;
diff --git a/src/libsync/discovery.cpp b/src/libsync/discovery.cpp
index b3da812fa8..77323cfe37 100644
--- a/src/libsync/discovery.cpp
+++ b/src/libsync/discovery.cpp
@@ -593,9 +593,25 @@ void ProcessDirectoryJob::processFileAnalyzeLocalInfo(
};
if (!localEntry.isValid()) {
+ const bool isNsfpMode = _discoveryData->_syncOptions._vfs->mode() == Vfs::Mode::MacOSNSFileProvider;
+ const bool isNsfpFile = isNsfpMode && dbEntry.isValid();
+
+ // NSFP mode: all files (virtual or hydrated) are managed by NSFileProvider,
+ // not the local filesystem. When the server deletes a file, remove the
+ // journal record directly — there is no local file for the sync engine
+ // to delete. The next metadata refresh will update Finder.
+ if (noServerEntry && isNsfpFile) {
+ qCInfo(lcDisco) << u"NSFP: server deleted file — removing journal record" << path._original;
+ _discoveryData->_statedb->deleteFileRecord(path._original, true);
+ return;
+ }
+
+ if (isNsfpFile) {
+ qCInfo(lcDisco) << u"NSFP: preserving file record (no local entry expected)" << path._original;
+ }
if (_queryLocal == ParentNotChanged && dbEntry.isValid()) {
// Not modified locally (ParentNotChanged)
- if (noServerEntry) {
+ if (noServerEntry && !isNsfpFile) {
// not on the server: Removed on the server, delete locally
item->setInstruction(CSYNC_INSTRUCTION_REMOVE);
item->_direction = SyncFileItem::Down;
@@ -610,8 +626,9 @@ void ProcessDirectoryJob::processFileAnalyzeLocalInfo(
qCInfo(lcDisco) << u"Stale DB entry";
_discoveryData->_statedb->deleteFileRecord(path._original, true);
return;
- } else if (!serverModified) {
+ } else if (!serverModified && !isNsfpFile) {
// Removed locally: also remove on the server.
+ // In NSFP mode, files are not on the local FS by design — do not treat as removed.
if (!dbEntry.serverHasIgnoredFiles()) {
item->setInstruction(CSYNC_INSTRUCTION_REMOVE);
item->_direction = SyncFileItem::Up;
diff --git a/src/libsync/vfs/vfs.cpp b/src/libsync/vfs/vfs.cpp
index 7261b2de0c..873189b6f3 100644
--- a/src/libsync/vfs/vfs.cpp
+++ b/src/libsync/vfs/vfs.cpp
@@ -28,6 +28,7 @@
#include
#include
#include
+#include
#include
#ifdef Q_OS_WIN
@@ -57,6 +58,8 @@ Optional Vfs::modeFromString(const QString &str)
return Mode::WindowsCfApi;
} else if (str == QLatin1String("openvfs")) {
return Mode::OpenVFS;
+ } else if (str == QLatin1String("nsfp")) {
+ return Mode::MacOSNSFileProvider;
}
return {};
}
@@ -73,6 +76,8 @@ QString Utility::enumToString(Vfs::Mode mode)
return QStringLiteral("off");
case Vfs::Mode::OpenVFS:
return QStringLiteral("openvfs");
+ case Vfs::Mode::MacOSNSFileProvider:
+ return QStringLiteral("nsfp");
}
Q_UNREACHABLE();
}
@@ -136,9 +141,18 @@ Vfs::Mode OCC::VfsPluginManager::bestAvailableVfsMode() const
{
if (isVfsPluginAvailable(Vfs::Mode::WindowsCfApi)) {
return Vfs::Mode::WindowsCfApi;
- } else if (isVfsPluginAvailable(Vfs::Mode::OpenVFS)) {
+ }
+#if defined(Q_OS_MACOS)
+ if (QOperatingSystemVersion::current() >= QOperatingSystemVersion::MacOSMonterey) {
+ if (isVfsPluginAvailable(Vfs::Mode::MacOSNSFileProvider)) {
+ return Vfs::Mode::MacOSNSFileProvider;
+ }
+ }
+#endif
+ if (isVfsPluginAvailable(Vfs::Mode::OpenVFS)) {
return Vfs::Mode::OpenVFS;
- } else if (isVfsPluginAvailable(Vfs::Mode::Off)) {
+ }
+ if (isVfsPluginAvailable(Vfs::Mode::Off)) {
return Vfs::Mode::Off;
}
Q_UNREACHABLE();
diff --git a/src/libsync/vfs/vfs.h b/src/libsync/vfs/vfs.h
index 3e8208f137..e432646c56 100644
--- a/src/libsync/vfs/vfs.h
+++ b/src/libsync/vfs/vfs.h
@@ -99,7 +99,7 @@ class OPENCLOUD_SYNC_EXPORT Vfs : public QObject
* Currently plugins and modes are one-to-one but that's not required.
* The raw integer values are used in Qml
*/
- enum class Mode : uint8_t { Off = 0, WindowsCfApi = 1, OpenVFS = 2 };
+ enum class Mode : uint8_t { Off = 0, WindowsCfApi = 1, OpenVFS = 2, MacOSNSFileProvider = 3 };
Q_ENUM(Mode)
enum class ConvertToPlaceholderResult : uint8_t { Ok, Locked };
Q_ENUM(ConvertToPlaceholderResult)
diff --git a/src/plugins/vfs/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..a1a5b1bef9
--- /dev/null
+++ b/src/plugins/vfs/nsfp/nsfpdomainmanager.h
@@ -0,0 +1,89 @@
+// NsfpDomainManager -- manages NSFileProviderDomain lifecycle for the macOS VFS plugin.
+#pragma once
+
+#include
+
+#include
+#include
+
+#ifdef __OBJC__
+#import
+#import
+#endif
+
+namespace OCC {
+
+/// Callback type for async domain operations.
+/// On success, errorMessage is empty. On failure, it contains a description.
+using NsfpDomainCompletionHandler = std::function;
+
+/// Manages the lifecycle of NSFileProviderDomain objects.
+///
+/// This is a pure Objective-C++ class (not a QObject) since it interfaces
+/// directly with NSFileProvider APIs. All NSFileProvider calls are dispatched
+/// on a dedicated serial queue. Results are bridged back to Qt via the
+/// completion handler, which callers are expected to invoke on their own
+/// thread (e.g. via QMetaObject::invokeMethod with Qt::QueuedConnection).
+///
+/// Domain registration is idempotent: if a domain with the given identifier
+/// already exists, the manager reconnects to it instead of creating a duplicate.
+class NsfpDomainManager
+{
+public:
+ NsfpDomainManager();
+ ~NsfpDomainManager();
+
+ // Non-copyable, non-movable
+ NsfpDomainManager(const NsfpDomainManager &) = delete;
+ NsfpDomainManager &operator=(const NsfpDomainManager &) = delete;
+
+ /// Register or reconnect to an NSFileProviderDomain.
+ /// The identifier must be stable across restarts (account UUID + space ID).
+ /// The displayName is shown in Finder sidebar.
+ /// Idempotent: if the domain already exists, reconnects without creating a duplicate.
+ void addDomain(const QString &identifier, const QString &displayName, NsfpDomainCompletionHandler completionHandler);
+
+ /// Fully remove an NSFileProviderDomain and delete its replica store.
+ void removeDomain(const QString &identifier, NsfpDomainCompletionHandler completionHandler);
+
+ /// Invalidate the manager for the given domain without removing it.
+ /// Used during app shutdown so files persist on disk.
+ void invalidateManager(const QString &identifier);
+
+ /// Signal the File Provider framework to re-enumerate items in the given container.
+ /// This causes Finder to refresh its view of that directory.
+ /// @param identifier The domain identifier.
+ /// @param containerId The container (folder) whose contents changed.
+ /// Use NSFileProviderRootContainerItemIdentifier for root.
+ void signalEnumerator(const QString &identifier, const QString &containerId);
+
+ /// Evict (dehydrate) a single item, freeing its local storage.
+ /// The item must have allowsEviction capability set in its FileProviderItem.
+ /// @param identifier The domain identifier.
+ /// @param fileId The NSFileProviderItemIdentifier of the item to evict.
+ /// @param completionHandler Called with empty string on success, error description on failure.
+ void evictItem(const QString &identifier, const QString &fileId, NsfpDomainCompletionHandler completionHandler);
+
+ /// Signal the 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;
+ std::unique_ptr _p;
+};
+
+} // namespace OCC
diff --git a/src/plugins/vfs/nsfp/nsfpdomainmanager.mm b/src/plugins/vfs/nsfp/nsfpdomainmanager.mm
new file mode 100644
index 0000000000..39ca5984f3
--- /dev/null
+++ b/src/plugins/vfs/nsfp/nsfpdomainmanager.mm
@@ -0,0 +1,460 @@
+// NsfpDomainManager implementation -- manages NSFileProviderDomain lifecycle.
+
+#include "nsfpdomainmanager.h"
+
+#include
+#include
+#include
+
+#import
+#import
+
+Q_LOGGING_CATEGORY(lcNsfpDomainManager, "sync.vfs.nsfp.domain", QtInfoMsg)
+
+namespace OCC {
+
+struct NsfpDomainManager::Private
+{
+ /// Serial dispatch queue for all NSFileProvider calls.
+ dispatch_queue_t dispatchQueue = dispatch_queue_create("eu.opencloud.vfs.nsfp.domain", DISPATCH_QUEUE_SERIAL);
+
+ /// Thread-safe cache of domain identifier -> NSFileProviderDomain.
+ QMutex cacheMutex;
+ QMap domainCache;
+ QMap managerCache;
+};
+
+NsfpDomainManager::NsfpDomainManager()
+ : _p(std::make_unique())
+{
+}
+
+NsfpDomainManager::~NsfpDomainManager()
+{
+ // Drain the serial queue so all pending blocks finish before _p is freed.
+ // Without this, in-flight dispatch_async blocks (e.g. from invalidateManager)
+ // can access _p->cacheMutex after it has been destroyed → use-after-free.
+ dispatch_sync(_p->dispatchQueue, ^{});
+
+ QMutexLocker lock(&_p->cacheMutex);
+ _p->domainCache.clear();
+ _p->managerCache.clear();
+}
+
+void NsfpDomainManager::addDomain(const QString &identifier, const QString &displayName,
+ NsfpDomainCompletionHandler completionHandler)
+{
+ qCInfo(lcNsfpDomainManager) << "addDomain requested:" << identifier << "displayName:" << displayName;
+
+ // Copy parameters by value — they are used in asynchronous blocks
+ // that outlive this function call.
+ QString identifierCopy = identifier;
+ NSString *nsIdentifier = identifier.toNSString();
+ NSString *nsDisplayName = displayName.toNSString();
+
+ // Capture completion handler by value for the block
+ auto handler = std::move(completionHandler);
+
+ dispatch_async(_p->dispatchQueue, ^{
+ // First, check if our domain already exists and is enabled
+ dispatch_semaphore_t listSemaphore = dispatch_semaphore_create(0);
+ __block NSError *listError = nil;
+ __block NSFileProviderDomain *existingDomain = nil;
+ __block NSMutableArray *staleDomainsToRemove = [NSMutableArray array];
+
+ [NSFileProviderManager getDomainsWithCompletionHandler:^(NSArray *domains, NSError *error) {
+ if (error) {
+ listError = error;
+ } else {
+ for (NSFileProviderDomain *domain in domains) {
+ if ([domain.identifier isEqualToString:nsIdentifier]) {
+ existingDomain = domain;
+ } else if ([domain.identifier hasPrefix:@"opencloud"]) {
+ // Remove stale opencloud domains with different identifiers
+ [staleDomainsToRemove addObject:domain];
+ }
+ }
+ }
+ dispatch_semaphore_signal(listSemaphore);
+ }];
+ dispatch_semaphore_wait(listSemaphore, DISPATCH_TIME_FOREVER);
+
+ if (listError) {
+ qCWarning(lcNsfpDomainManager) << "Failed to list existing domains:" << QString::fromNSString(listError.localizedDescription);
+ }
+
+ // If the domain already exists, force-remove it so fileproviderd re-resolves
+ // the extension UUID from pluginkit on the subsequent addDomain call.
+ // Reusing the existing domain would leave fileproviderd bound to the old
+ // extension UUID (e.g. after re-signing the appex), causing ETIMEDOUT on fetch.
+ if (existingDomain) {
+ qCInfo(lcNsfpDomainManager) << "Domain already exists, removing for clean re-add:" << identifierCopy
+ << "userEnabled:" << existingDomain.userEnabled;
+ dispatch_semaphore_t removeSem = dispatch_semaphore_create(0);
+ [NSFileProviderManager removeDomain:existingDomain completionHandler:^(NSError *removeErr) {
+ if (removeErr) {
+ qCWarning(lcNsfpDomainManager) << "Failed to remove existing domain for re-add:"
+ << QString::fromNSString(removeErr.localizedDescription);
+ } else {
+ qCInfo(lcNsfpDomainManager) << "Existing domain removed — will re-add fresh:" << identifierCopy;
+ }
+ dispatch_semaphore_signal(removeSem);
+ }];
+ dispatch_semaphore_wait(removeSem, DISPATCH_TIME_FOREVER);
+ // Fall through to the addDomain path below so fileproviderd picks up the
+ // current pluginkit extension UUID.
+ }
+
+ // Remove stale domains before creating a new one
+ for (NSFileProviderDomain *staleDomain in staleDomainsToRemove) {
+ qCInfo(lcNsfpDomainManager) << "Removing stale domain:"
+ << QString::fromNSString(staleDomain.identifier)
+ << "userEnabled:" << staleDomain.userEnabled;
+
+ dispatch_semaphore_t removeSem = dispatch_semaphore_create(0);
+ [NSFileProviderManager removeDomain:staleDomain completionHandler:^(NSError *removeErr) {
+ if (removeErr) {
+ qCWarning(lcNsfpDomainManager) << "Failed to remove stale domain:"
+ << QString::fromNSString(removeErr.localizedDescription);
+ } else {
+ qCInfo(lcNsfpDomainManager) << "Stale domain removed successfully:"
+ << QString::fromNSString(staleDomain.identifier);
+ }
+ dispatch_semaphore_signal(removeSem);
+ }];
+ dispatch_semaphore_wait(removeSem, DISPATCH_TIME_FOREVER);
+ }
+
+ // Create a new domain (only when no existing domain was found)
+ NSFileProviderDomain *domain = [[NSFileProviderDomain alloc] initWithIdentifier:nsIdentifier
+ displayName:nsDisplayName];
+
+ [NSFileProviderManager addDomain:domain completionHandler:^(NSError *error) {
+ if (error) {
+ QString errorMsg = QString::fromNSString(error.localizedDescription);
+ qCWarning(lcNsfpDomainManager) << "Failed to add domain:" << identifierCopy << "error:" << errorMsg;
+
+ // The domain may already be registered in fileproviderd (e.g. addDomain failed
+ // because getDomainsWithCompletionHandler also failed with -2001 during init,
+ // so we fell through to the create path even though the domain exists).
+ // Try to obtain a manager anyway — if the domain is registered, this succeeds
+ // and we can still call reimportItemsBelowItemWithIdentifier to wake the extension.
+ NSFileProviderManager *fallbackManager = [NSFileProviderManager managerForDomain:domain];
+ if (fallbackManager) {
+ qCInfo(lcNsfpDomainManager) << "addDomain failed but domain is registered; attempting fallback reimport for:" << identifierCopy;
+ [fallbackManager reimportItemsBelowItemWithIdentifier:NSFileProviderRootContainerItemIdentifier
+ completionHandler:^(NSError *reimportErr) {
+ if (reimportErr) {
+ qCWarning(lcNsfpDomainManager) << "Fallback reimport failed:"
+ << QString::fromNSString(reimportErr.localizedDescription);
+ } else {
+ qCInfo(lcNsfpDomainManager) << "Fallback reimport succeeded — extension should wake";
+ }
+ }];
+ {
+ QMutexLocker lock(&_p->cacheMutex);
+ _p->domainCache[identifierCopy] = domain;
+ _p->managerCache[identifierCopy] = fallbackManager;
+ }
+ if (handler) {
+ handler(QString()); // treat as success — we have a live manager
+ }
+ return;
+ }
+
+ if (handler) {
+ handler(errorMsg);
+ }
+ return;
+ }
+
+ qCInfo(lcNsfpDomainManager) << "Domain added successfully:" << identifierCopy;
+
+ NSFileProviderManager *manager = [NSFileProviderManager managerForDomain:domain];
+
+ // Force re-enumeration to clear any backoff state from previous sessions.
+ [manager reimportItemsBelowItemWithIdentifier:NSFileProviderRootContainerItemIdentifier
+ completionHandler:^(NSError *reimportErr) {
+ if (reimportErr) {
+ qCWarning(lcNsfpDomainManager) << "reimportItems (new domain) failed:"
+ << QString::fromNSString(reimportErr.localizedDescription);
+ } else {
+ qCInfo(lcNsfpDomainManager) << "reimportItems (new domain) succeeded — fileproviderd will re-enumerate";
+ }
+ }];
+
+ // Check userEnabled status of the newly added domain
+ qCInfo(lcNsfpDomainManager) << "New domain userEnabled:" << domain.userEnabled;
+
+ if (!domain.userEnabled) {
+ // Try reconnect on the new domain too
+ qCInfo(lcNsfpDomainManager) << "New domain is user-disabled, attempting reconnect...";
+ [manager reconnectWithCompletionHandler:^(NSError *reconnectError) {
+ if (reconnectError) {
+ qCWarning(lcNsfpDomainManager) << "Reconnect on new domain failed:"
+ << QString::fromNSString(reconnectError.localizedDescription);
+ } else {
+ qCInfo(lcNsfpDomainManager) << "Reconnect on new domain succeeded!";
+ }
+ }];
+ }
+
+ {
+ QMutexLocker lock(&_p->cacheMutex);
+ _p->domainCache[identifierCopy] = domain;
+ _p->managerCache[identifierCopy] = manager;
+ }
+
+ if (handler) {
+ handler(QString());
+ }
+ }];
+ });
+}
+
+void NsfpDomainManager::removeDomain(const QString &identifier,
+ NsfpDomainCompletionHandler completionHandler)
+{
+ qCInfo(lcNsfpDomainManager) << "removeDomain requested:" << identifier;
+
+ QString identifierCopy = identifier;
+ auto handler = std::move(completionHandler);
+
+ dispatch_async(_p->dispatchQueue, ^{
+ NSFileProviderDomain *domain = nil;
+ {
+ QMutexLocker lock(&_p->cacheMutex);
+ domain = _p->domainCache.value(identifierCopy, nil);
+ }
+
+ if (!domain) {
+ qCWarning(lcNsfpDomainManager) << "removeDomain: domain not found in cache:" << identifierCopy;
+ if (handler) {
+ handler(QStringLiteral("Domain not found: %1").arg(identifierCopy));
+ }
+ return;
+ }
+
+ [NSFileProviderManager removeDomain:domain completionHandler:^(NSError *error) {
+ if (error) {
+ QString errorMsg = QString::fromNSString(error.localizedDescription);
+ qCWarning(lcNsfpDomainManager) << "Failed to remove domain:" << identifierCopy << "error:" << errorMsg;
+
+ if (handler) {
+ handler(errorMsg);
+ }
+ return;
+ }
+
+ qCInfo(lcNsfpDomainManager) << "Domain removed successfully:" << identifierCopy;
+
+ {
+ QMutexLocker lock(&_p->cacheMutex);
+ _p->domainCache.remove(identifierCopy);
+ _p->managerCache.remove(identifierCopy);
+ }
+
+ if (handler) {
+ handler(QString());
+ }
+ }];
+ });
+}
+
+void NsfpDomainManager::invalidateManager(const QString &identifier)
+{
+ qCInfo(lcNsfpDomainManager) << "invalidateManager requested:" << identifier;
+
+ QString identifierCopy = identifier;
+
+ dispatch_async(_p->dispatchQueue, ^{
+ NSFileProviderManager *manager = nil;
+ {
+ QMutexLocker lock(&_p->cacheMutex);
+ manager = _p->managerCache.value(identifierCopy, nil);
+ }
+
+ if (manager) {
+
+ qCInfo(lcNsfpDomainManager) << "Manager invalidated for domain:" << identifierCopy;
+ } else {
+ qCDebug(lcNsfpDomainManager) << "invalidateManager: no manager cached for:" << identifierCopy;
+ }
+
+ {
+ QMutexLocker lock(&_p->cacheMutex);
+ _p->managerCache.remove(identifierCopy);
+ // Keep the domain in cache so it can be reconnected later
+ }
+ });
+}
+
+NSFileProviderManager *NsfpDomainManager::managerForIdentifier(const QString &identifier)
+{
+ QMutexLocker lock(&_p->cacheMutex);
+
+ // Return cached manager if available
+ auto managerIt = _p->managerCache.find(identifier);
+ if (managerIt != _p->managerCache.end()) {
+ return managerIt.value();
+ }
+
+ // Try to create from cached domain
+ auto domainIt = _p->domainCache.find(identifier);
+ if (domainIt != _p->domainCache.end()) {
+ NSFileProviderManager *manager = [NSFileProviderManager managerForDomain:domainIt.value()];
+ _p->managerCache[identifier] = manager;
+ return manager;
+ }
+
+ qCDebug(lcNsfpDomainManager) << "managerForIdentifier: no domain registered for:" << identifier;
+ return nil;
+}
+
+void NsfpDomainManager::signalEnumerator(const QString &identifier, const QString &containerId)
+{
+ qCInfo(lcNsfpDomainManager) << "signalEnumerator requested for domain:" << identifier
+ << "container:" << containerId;
+
+ // Copy parameters by value — they must survive past this function's return
+ // since they are used in asynchronous blocks.
+ QString identifierCopy = identifier;
+ QString containerIdCopy = containerId;
+ NSString *nsContainerId = containerId.toNSString();
+
+ dispatch_async(_p->dispatchQueue, ^{
+ NSFileProviderManager *manager = nil;
+ {
+ QMutexLocker lock(&_p->cacheMutex);
+ manager = _p->managerCache.value(identifierCopy, nil);
+ }
+
+ if (!manager) {
+ qCWarning(lcNsfpDomainManager) << "signalEnumerator: no manager for domain:" << identifierCopy;
+ return;
+ }
+
+ NSFileProviderItemIdentifier itemId = nsContainerId;
+ if (containerIdCopy.isEmpty()) {
+ itemId = NSFileProviderRootContainerItemIdentifier;
+ }
+
+ [manager signalEnumeratorForContainerItemIdentifier:itemId
+ completionHandler:^(NSError *error) {
+ if (error) {
+ qCWarning(lcNsfpDomainManager) << "signalEnumerator failed:"
+ << QString::fromNSString(error.localizedDescription);
+ } else {
+ qCDebug(lcNsfpDomainManager) << "signalEnumerator succeeded for container:" << containerIdCopy;
+ }
+ }];
+ });
+}
+
+void NsfpDomainManager::signalWorkingSet(const QString &identifier)
+{
+ qCInfo(lcNsfpDomainManager) << "signalWorkingSet requested for domain:" << identifier;
+
+ QString identifierCopy = identifier;
+ dispatch_async(_p->dispatchQueue, ^{
+ NSFileProviderManager *manager = nil;
+ {
+ QMutexLocker lock(&_p->cacheMutex);
+ manager = _p->managerCache.value(identifierCopy, nil);
+ }
+
+ if (!manager) {
+ qCWarning(lcNsfpDomainManager) << "signalWorkingSet: no manager for domain:" << identifierCopy;
+ return;
+ }
+
+ [manager signalEnumeratorForContainerItemIdentifier:NSFileProviderWorkingSetContainerItemIdentifier
+ completionHandler:^(NSError *error) {
+ if (error) {
+ qCWarning(lcNsfpDomainManager) << "signalWorkingSet failed:"
+ << QString::fromNSString(error.localizedDescription);
+ } else {
+ qCDebug(lcNsfpDomainManager) << "signalWorkingSet succeeded for domain:" << identifierCopy;
+ }
+ }];
+ });
+}
+
+void NsfpDomainManager::evictItem(const QString &identifier, const QString &fileId,
+ NsfpDomainCompletionHandler completionHandler)
+{
+ qCInfo(lcNsfpDomainManager) << "evictItem requested for domain:" << identifier
+ << "fileId:" << fileId;
+
+ QString identifierCopy = identifier;
+ QString fileIdCopy = fileId;
+ NSString *nsFileId = fileId.toNSString();
+ auto handler = std::move(completionHandler);
+
+ dispatch_async(_p->dispatchQueue, ^{
+ NSFileProviderManager *manager = nil;
+ {
+ QMutexLocker lock(&_p->cacheMutex);
+ manager = _p->managerCache.value(identifierCopy, nil);
+ }
+
+ if (!manager) {
+ qCWarning(lcNsfpDomainManager) << "evictItem: no manager for domain:" << identifierCopy;
+ if (handler) {
+ handler(QStringLiteral("No manager for domain: %1").arg(identifierCopy));
+ }
+ return;
+ }
+
+ [manager evictItemWithIdentifier:nsFileId
+ completionHandler:^(NSError *error) {
+ if (error) {
+ const auto errorMsg = QString::fromNSString(error.localizedDescription);
+ qCWarning(lcNsfpDomainManager) << "evictItem failed for fileId:" << fileIdCopy
+ << "error:" << errorMsg;
+ if (handler) {
+ handler(errorMsg);
+ }
+ } else {
+ qCInfo(lcNsfpDomainManager) << "evictItem succeeded for fileId:" << fileIdCopy;
+ if (handler) {
+ handler(QString());
+ }
+ }
+ }];
+ });
+}
+
+void NsfpDomainManager::requestSystemEviction(const QString &identifier)
+{
+ qCInfo(lcNsfpDomainManager) << "requestSystemEviction for domain:" << identifier;
+
+ QString identifierCopy = identifier;
+
+ dispatch_async(_p->dispatchQueue, ^{
+ NSFileProviderManager *manager = nil;
+ {
+ QMutexLocker lock(&_p->cacheMutex);
+ manager = _p->managerCache.value(identifierCopy, nil);
+ }
+
+ if (!manager) {
+ qCWarning(lcNsfpDomainManager) << "requestSystemEviction: no manager for domain:" << identifierCopy;
+ return;
+ }
+
+ // Signal the working set enumerator to let the system decide what to evict
+ // based on allowsEviction capability and last-access timestamps.
+ [manager signalEnumeratorForContainerItemIdentifier:NSFileProviderWorkingSetContainerItemIdentifier
+ completionHandler:^(NSError *error) {
+ if (error) {
+ qCWarning(lcNsfpDomainManager) << "requestSystemEviction signal failed:"
+ << QString::fromNSString(error.localizedDescription);
+ } else {
+ qCInfo(lcNsfpDomainManager) << "requestSystemEviction signal sent successfully";
+ }
+ }];
+ });
+}
+
+} // namespace OCC
diff --git a/src/plugins/vfs/nsfp/nsfpxpchandler.h b/src/plugins/vfs/nsfp/nsfpxpchandler.h
new file mode 100644
index 0000000000..3771047835
--- /dev/null
+++ b/src/plugins/vfs/nsfp/nsfpxpchandler.h
@@ -0,0 +1,53 @@
+// NsfpXpcHandler -- XPC listener in the main app that handles hydration,
+// enumeration, and pin-state requests from the File Provider extension.
+#pragma once
+
+#include
+#include
+
+#include
+#include
+
+#ifdef __OBJC__
+#import
+#endif
+
+namespace OCC {
+
+class Vfs;
+
+/// Handles incoming XPC calls from the NSFileProvider extension process.
+///
+/// The extension connects to the main app via a Mach-service-based
+/// NSXPCListener. This class vends an Objective-C object that conforms to
+/// OpenCloudXPCServiceProtocol and forwards requests into the Qt event loop.
+///
+/// Thread safety: all SyncJournalDb and HydrationJob work is dispatched to
+/// the Qt main thread via QMetaObject::invokeMethod. The XPC listener and
+/// its delegate live on a GCD serial queue.
+class NsfpXpcHandler : public QObject
+{
+ Q_OBJECT
+
+public:
+ explicit NsfpXpcHandler(Vfs *vfs, QObject *parent = nullptr);
+ ~NsfpXpcHandler() override;
+
+ // Non-copyable, non-movable
+ NsfpXpcHandler(const NsfpXpcHandler &) = delete;
+ NsfpXpcHandler &operator=(const NsfpXpcHandler &) = delete;
+
+ /// Start the NSXPCListener. Must be called after VfsSetupParams are available.
+ void startListener();
+
+ /// Stop the listener and abort any in-flight hydration jobs.
+ void stopListener();
+
+private:
+ struct Private;
+ std::unique_ptr _p;
+
+ Vfs *_vfs = nullptr;
+};
+
+} // namespace OCC
diff --git a/src/plugins/vfs/nsfp/nsfpxpchandler.mm b/src/plugins/vfs/nsfp/nsfpxpchandler.mm
new file mode 100644
index 0000000000..5b003486e3
--- /dev/null
+++ b/src/plugins/vfs/nsfp/nsfpxpchandler.mm
@@ -0,0 +1,507 @@
+// NsfpXpcHandler implementation -- XPC listener in the main app that handles
+// hydration, enumeration, and pin-state requests from the File Provider extension.
+
+#include "nsfpxpchandler.h"
+
+#include "common/syncjournaldb.h"
+#include "common/syncjournalfilerecord.h"
+#include "libsync/vfs/hydrationjob.h"
+#include "libsync/vfs/vfs.h"
+
+#include
+#include
+#include
+#include
+
+#import
+
+// Import the shared XPC protocol definition from the extension sources.
+// The protocol header is self-contained (no ObjC++ / Qt dependencies).
+#import "FileProviderXPCService.h"
+
+Q_LOGGING_CATEGORY(lcNsfpXpc, "sync.vfs.nsfp.xpc", QtInfoMsg)
+
+static const int ENUMERATE_PAGE_SIZE = 500;
+
+using namespace OCC;
+
+// ---------------------------------------------------------------------------
+// Objective-C delegate that conforms to OpenCloudXPCServiceProtocol.
+// All heavy lifting is forwarded to the Qt event loop via QPointer + invokeMethod.
+// ---------------------------------------------------------------------------
+
+@interface NsfpXpcDelegate : NSObject
+- (instancetype)initWithVfs:(QPointer)vfs handler:(QPointer)handler;
+@end
+
+@implementation NsfpXpcDelegate {
+ QPointer _vfs;
+ QPointer _handler;
+
+ /// Guards against duplicate hydration requests for the same fileId.
+ /// Key: fileId (NSString*). Value: array of pending completion handlers.
+ NSMutableDictionary *_inflightHydrations;
+}
+
+- (instancetype)initWithVfs:(QPointer)vfs handler:(QPointer)handler {
+ self = [super init];
+ if (self) {
+ _vfs = vfs;
+ _handler = handler;
+ _inflightHydrations = [NSMutableDictionary dictionary];
+ }
+ return self;
+}
+
+#pragma mark - NSXPCListenerDelegate
+
+- (BOOL)listener:(NSXPCListener *)listener shouldAcceptNewConnection:(NSXPCConnection *)newConnection {
+ Q_UNUSED(listener)
+
+ qCInfo(lcNsfpXpc) << "Accepting new XPC connection from extension";
+
+ newConnection.exportedInterface = [NSXPCInterface interfaceWithProtocol:@protocol(OpenCloudXPCServiceProtocol)];
+ newConnection.exportedObject = self;
+
+ __weak NSXPCConnection *weakConn = newConnection;
+ newConnection.invalidationHandler = ^{
+ qCInfo(lcNsfpXpc) << "XPC connection invalidated";
+ Q_UNUSED(weakConn)
+ };
+ newConnection.interruptionHandler = ^{
+ qCInfo(lcNsfpXpc) << "XPC connection interrupted";
+ };
+
+ [newConnection resume];
+ return YES;
+}
+
+#pragma mark - OpenCloudXPCServiceProtocol
+
+- (void)requestHydration:(NSString *)fileId
+ targetURL:(NSURL *)url
+ completionHandler:(void (^)(NSError * _Nullable))completionHandler {
+
+ NSString *fileIdCopy = [fileId copy];
+ NSURL *urlCopy = [url copy];
+ auto handler = [completionHandler copy];
+
+ qCInfo(lcNsfpXpc) << "requestHydration fileId:" << QString::fromNSString(fileIdCopy)
+ << "target:" << QString::fromNSString(urlCopy.path);
+
+ // Coalesce: if a hydration for the same fileId is already in flight, queue the callback.
+ @synchronized (_inflightHydrations) {
+ NSMutableArray *pending = _inflightHydrations[fileIdCopy];
+ if (pending) {
+ qCInfo(lcNsfpXpc) << "Coalescing hydration request for fileId:" << QString::fromNSString(fileIdCopy);
+ [pending addObject:handler];
+ return;
+ }
+ _inflightHydrations[fileIdCopy] = [NSMutableArray arrayWithObject:handler];
+ }
+
+ QPointer vfs = _vfs;
+ __weak __typeof__(self) weakSelf = self;
+
+ QMetaObject::invokeMethod(vfs, [vfs, fileIdCopy, urlCopy, weakSelf]() {
+ if (!vfs) {
+ qCWarning(lcNsfpXpc) << "Vfs gone during hydration request";
+ [weakSelf completeHydration:fileIdCopy
+ withError:[NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorServerUnreachable
+ userInfo:@{NSLocalizedDescriptionKey: @"Sync engine unavailable"}]];
+ return;
+ }
+
+ const auto qFileId = QString::fromNSString(fileIdCopy).toUtf8();
+ const auto targetPath = QString::fromNSString(urlCopy.path);
+
+ // Open a QFile as the output device for HydrationJob.
+ auto device = std::make_unique(targetPath);
+
+ auto *job = new HydrationJob(vfs, qFileId, std::move(device), vfs);
+ job->setTargetFile(targetPath);
+
+ QObject::connect(job, &HydrationJob::finished, vfs, [weakSelf, fileIdCopy, job]() {
+ qCInfo(lcNsfpXpc) << "Hydration finished successfully for fileId:" << QString::fromNSString(fileIdCopy);
+ [weakSelf completeHydration:fileIdCopy withError:nil];
+ job->deleteLater();
+ });
+ QObject::connect(job, &HydrationJob::error, vfs, [weakSelf, fileIdCopy, job](const QString &errorMsg) {
+ qCWarning(lcNsfpXpc) << "Hydration error for fileId:" << QString::fromNSString(fileIdCopy) << errorMsg;
+ NSError *nsError = [NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorServerUnreachable
+ userInfo:@{NSLocalizedDescriptionKey: errorMsg.toNSString()}];
+ [weakSelf completeHydration:fileIdCopy withError:nsError];
+ job->deleteLater();
+ });
+
+ job->start();
+ }, Qt::QueuedConnection);
+}
+
+/// Internal helper: resolve all queued completion handlers for a hydration request.
+- (void)completeHydration:(NSString *)fileId withError:(NSError * _Nullable)error {
+ NSArray *handlers = nil;
+ @synchronized (_inflightHydrations) {
+ handlers = [_inflightHydrations[fileId] copy];
+ [_inflightHydrations removeObjectForKey:fileId];
+ }
+ for (void (^h)(NSError *) in handlers) {
+ h(error);
+ }
+}
+
+- (void)scheduleUpload:(NSURL *)localURL
+ parentIdentifier:(NSString *)parentId
+ completionHandler:(void (^)(NSString * _Nullable, NSError * _Nullable))completionHandler {
+
+ qCInfo(lcNsfpXpc) << "scheduleUpload — stub, not yet implemented";
+
+ NSError *error = [NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorServerUnreachable
+ userInfo:@{NSLocalizedDescriptionKey: @"Upload scheduling not yet implemented"}];
+ completionHandler(nil, error);
+}
+
+- (void)requestPinState:(NSString *)fileId
+ completionHandler:(void (^)(NSInteger, NSError * _Nullable))completionHandler {
+
+ NSString *fileIdCopy = [fileId copy];
+ QPointer vfs = _vfs;
+
+ QMetaObject::invokeMethod(vfs, [vfs, fileIdCopy, completionHandler]() {
+ if (!vfs) {
+ completionHandler(0, [NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorServerUnreachable
+ userInfo:@{NSLocalizedDescriptionKey: @"Sync engine unavailable"}]);
+ return;
+ }
+
+ // Look up the record by fileId to get its relative path, then query pinState.
+ auto *journal = vfs->params().journal;
+ if (!journal) {
+ completionHandler(0, [NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorServerUnreachable
+ userInfo:@{NSLocalizedDescriptionKey: @"No journal available"}]);
+ return;
+ }
+
+ QString relPath;
+ const auto qFileId = QString::fromNSString(fileIdCopy).toUtf8();
+ journal->getFileRecordsByFileId(qFileId, [&relPath](const SyncJournalFileRecord &record) {
+ if (record.isValid()) {
+ relPath = record.path();
+ }
+ });
+
+ if (relPath.isEmpty()) {
+ completionHandler(0, [NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorNoSuchItem
+ userInfo:@{NSLocalizedDescriptionKey: @"Item not found in journal"}]);
+ return;
+ }
+
+ auto state = vfs->pinState(relPath);
+ if (state) {
+ completionHandler(static_cast(*state), nil);
+ } else {
+ // No explicit pin state -- return Inherited (0)
+ completionHandler(static_cast(PinState::Inherited), nil);
+ }
+ }, Qt::QueuedConnection);
+}
+
+- (void)setPinState:(NSInteger)pinState
+ forFileId:(NSString *)fileId
+ completionHandler:(void (^)(NSError * _Nullable))completionHandler {
+
+ NSString *fileIdCopy = [fileId copy];
+ QPointer vfs = _vfs;
+
+ QMetaObject::invokeMethod(vfs, [vfs, fileIdCopy, pinState, completionHandler]() {
+ if (!vfs) {
+ completionHandler([NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorServerUnreachable
+ userInfo:@{NSLocalizedDescriptionKey: @"Sync engine unavailable"}]);
+ return;
+ }
+
+ auto *journal = vfs->params().journal;
+ if (!journal) {
+ completionHandler([NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorServerUnreachable
+ userInfo:@{NSLocalizedDescriptionKey: @"No journal available"}]);
+ return;
+ }
+
+ QString relPath;
+ const auto qFileId = QString::fromNSString(fileIdCopy).toUtf8();
+ journal->getFileRecordsByFileId(qFileId, [&relPath](const SyncJournalFileRecord &record) {
+ if (record.isValid()) {
+ relPath = record.path();
+ }
+ });
+
+ if (relPath.isEmpty()) {
+ completionHandler([NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorNoSuchItem
+ userInfo:@{NSLocalizedDescriptionKey: @"Item not found in journal"}]);
+ return;
+ }
+
+ const auto state = static_cast(pinState);
+ const bool ok = vfs->setPinState(relPath, state);
+ if (ok) {
+ completionHandler(nil);
+ } else {
+ completionHandler([NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorServerUnreachable
+ userInfo:@{NSLocalizedDescriptionKey: @"Failed to set pin state"}]);
+ }
+ }, Qt::QueuedConnection);
+}
+
+- (void)ping:(void (^)(BOOL))handler {
+ qCDebug(lcNsfpXpc) << "ping received";
+ handler(YES);
+}
+
+- (void)enumerateItems:(NSString *)containerId
+ cursor:(NSString *)cursor
+ completionHandler:(void (^)(NSArray * _Nullable,
+ NSString * _Nullable,
+ NSError * _Nullable))completionHandler {
+
+ NSString *containerIdCopy = [containerId copy];
+ NSString *cursorCopy = [cursor copy];
+ QPointer vfs = _vfs;
+
+ QMetaObject::invokeMethod(vfs, [vfs, containerIdCopy, cursorCopy, completionHandler]() {
+ if (!vfs) {
+ completionHandler(nil, nil,
+ [NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorServerUnreachable
+ userInfo:@{NSLocalizedDescriptionKey: @"Sync engine unavailable"}]);
+ return;
+ }
+
+ auto *journal = vfs->params().journal;
+ if (!journal) {
+ completionHandler(nil, nil,
+ [NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorServerUnreachable
+ userInfo:@{NSLocalizedDescriptionKey: @"No journal available"}]);
+ return;
+ }
+
+ const auto qContainerId = QString::fromNSString(containerIdCopy);
+ const int offset = QString::fromNSString(cursorCopy).toInt(); // empty -> 0
+
+ // Determine the parent path. Root container => enumerate top-level items.
+ QString parentPath;
+ if (!qContainerId.isEmpty()) {
+ // Look up the path for this fileId
+ const auto qFileIdBytes = qContainerId.toUtf8();
+ journal->getFileRecordsByFileId(qFileIdBytes, [&parentPath](const SyncJournalFileRecord &record) {
+ if (record.isValid()) {
+ parentPath = record.path();
+ }
+ });
+ if (parentPath.isEmpty()) {
+ completionHandler(nil, nil,
+ [NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorNoSuchItem
+ userInfo:@{NSLocalizedDescriptionKey: @"Container not found in journal"}]);
+ return;
+ }
+ }
+ // parentPath empty means root
+
+ // Collect children from the journal
+ QVector children;
+ journal->listFilesInPath(parentPath, [&children](const SyncJournalFileRecord &record) {
+ children.append(record);
+ });
+
+ // Apply pagination
+ const int total = children.size();
+ const int start = qMin(offset, total);
+ const int end = qMin(start + ENUMERATE_PAGE_SIZE, total);
+
+ NSMutableArray *items = [NSMutableArray arrayWithCapacity:end - start];
+ for (int i = start; i < end; ++i) {
+ const auto &rec = children[i];
+ NSDictionary *dict = @{
+ @"fileId" : QString::fromUtf8(rec.fileId()).toNSString(),
+ @"path" : rec.path().toNSString(),
+ @"name" : rec.name().toNSString(),
+ @"isDirectory" : @(rec.isDirectory()),
+ @"size" : @(rec.size()),
+ @"modtime" : @(rec.modtime()),
+ @"etag" : rec.etag().toNSString(),
+ @"isVirtualFile" : @(rec.isVirtualFile()),
+ };
+ [items addObject:dict];
+ }
+
+ NSString *nextCursor = nil;
+ if (end < total) {
+ nextCursor = [NSString stringWithFormat:@"%d", end];
+ }
+
+ completionHandler(items, nextCursor, nil);
+ }, Qt::QueuedConnection);
+}
+
+- (void)itemForIdentifier:(NSString *)identifier
+ completionHandler:(void (^)(NSDictionary * _Nullable,
+ NSError * _Nullable))completionHandler {
+
+ NSString *identifierCopy = [identifier copy];
+ QPointer vfs = _vfs;
+
+ QMetaObject::invokeMethod(vfs, [vfs, identifierCopy, completionHandler]() {
+ if (!vfs) {
+ completionHandler(nil,
+ [NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorServerUnreachable
+ userInfo:@{NSLocalizedDescriptionKey: @"Sync engine unavailable"}]);
+ return;
+ }
+
+ auto *journal = vfs->params().journal;
+ if (!journal) {
+ completionHandler(nil,
+ [NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorServerUnreachable
+ userInfo:@{NSLocalizedDescriptionKey: @"No journal available"}]);
+ return;
+ }
+
+ const auto qFileId = QString::fromNSString(identifierCopy).toUtf8();
+ SyncJournalFileRecord found;
+ journal->getFileRecordsByFileId(qFileId, [&found](const SyncJournalFileRecord &record) {
+ if (record.isValid() && !found.isValid()) {
+ found = record;
+ }
+ });
+
+ if (!found.isValid()) {
+ completionHandler(nil,
+ [NSError errorWithDomain:NSFileProviderErrorDomain
+ code:NSFileProviderErrorNoSuchItem
+ userInfo:@{NSLocalizedDescriptionKey: @"Item not found in journal"}]);
+ return;
+ }
+
+ NSDictionary *dict = @{
+ @"fileId" : QString::fromUtf8(found.fileId()).toNSString(),
+ @"path" : found.path().toNSString(),
+ @"name" : found.name().toNSString(),
+ @"isDirectory" : @(found.isDirectory()),
+ @"size" : @(found.size()),
+ @"modtime" : @(found.modtime()),
+ @"etag" : found.etag().toNSString(),
+ @"isVirtualFile" : @(found.isVirtualFile()),
+ };
+
+ completionHandler(dict, nil);
+ }, Qt::QueuedConnection);
+}
+
+@end
+
+// ---------------------------------------------------------------------------
+// C++ Private implementation (PIMPL)
+// ---------------------------------------------------------------------------
+
+namespace OCC {
+
+struct NsfpXpcHandler::Private
+{
+ NSXPCListener *listener = nil;
+ NsfpXpcDelegate *delegate = nil;
+};
+
+NsfpXpcHandler::NsfpXpcHandler(Vfs *vfs, QObject *parent)
+ : QObject(parent)
+ , _p(std::make_unique())
+ , _vfs(vfs)
+{
+}
+
+NsfpXpcHandler::~NsfpXpcHandler()
+{
+ stopListener();
+}
+
+void NsfpXpcHandler::startListener()
+{
+ if (_p->listener) {
+ qCDebug(lcNsfpXpc) << "Listener already started";
+ return;
+ }
+
+ qCInfo(lcNsfpXpc) << "Starting anonymous NSXPCListener (endpoint shared via App Group container)";
+
+ _p->delegate = [[NsfpXpcDelegate alloc] initWithVfs:QPointer(_vfs)
+ handler:QPointer(this)];
+
+ // Use an anonymous listener instead of initWithMachServiceName: because
+ // unsandboxed apps cannot register Mach services with launchd.
+ _p->listener = [NSXPCListener anonymousListener];
+ _p->listener.delegate = _p->delegate;
+ [_p->listener resume];
+
+ // Write the listener endpoint to the App Group container so the extension
+ // can establish an XPC connection. NSXPCListenerEndpoint conforms to
+ // NSSecureCoding; the serialized form carries a Mach send right that the
+ // kernel transfers to whichever process unarchives the data. The endpoint
+ // becomes invalid when this process exits, but that is expected — the
+ // extension will retry on the next launch.
+ NSURL *containerURL = [[NSFileManager defaultManager]
+ containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier];
+ if (containerURL) {
+ NSXPCListenerEndpoint *endpoint = _p->listener.endpoint;
+ if (endpoint) {
+ NSError *archiveError = nil;
+ NSData *data = [NSKeyedArchiver archivedDataWithRootObject:endpoint
+ requiringSecureCoding:YES
+ error:&archiveError];
+ if (data && !archiveError) {
+ NSURL *endpointURL = [containerURL URLByAppendingPathComponent:kOpenCloudXPCEndpointFilename];
+ [data writeToURL:endpointURL atomically:YES];
+ qCInfo(lcNsfpXpc) << "XPC endpoint written to App Group container";
+ } else {
+ qCWarning(lcNsfpXpc) << "Failed to archive XPC endpoint:"
+ << QString::fromNSString(archiveError.localizedDescription);
+ }
+ }
+ }
+
+ qCInfo(lcNsfpXpc) << "NSXPCListener started";
+}
+
+void NsfpXpcHandler::stopListener()
+{
+ if (!_p->listener) {
+ return;
+ }
+
+ qCInfo(lcNsfpXpc) << "Stopping NSXPCListener";
+ [_p->listener invalidate];
+ _p->listener = nil;
+ _p->delegate = nil;
+
+ // Remove the endpoint file so the extension does not try to connect
+ // to a dead listener.
+ NSURL *containerURL = [[NSFileManager defaultManager]
+ containerURLForSecurityApplicationGroupIdentifier:kOpenCloudAppGroupIdentifier];
+ if (containerURL) {
+ NSURL *endpointURL = [containerURL URLByAppendingPathComponent:kOpenCloudXPCEndpointFilename];
+ [[NSFileManager defaultManager] removeItemAtURL:endpointURL error:nil];
+ }
+}
+
+} // namespace OCC
diff --git a/src/plugins/vfs/nsfp/vfs_nsfp.h b/src/plugins/vfs/nsfp/vfs_nsfp.h
new file mode 100644
index 0000000000..286ed65567
--- /dev/null
+++ b/src/plugins/vfs/nsfp/vfs_nsfp.h
@@ -0,0 +1,85 @@
+// VfsNSFP header -- macOS NSFileProvider-based virtual file system plugin.
+#pragma once
+
+#include "common/plugin.h"
+#include "vfs/vfs.h"
+
+#include
+#include
+#include
+#include
+
+#include
+
+#ifdef __OBJC__
+#import
+#import
+#endif
+
+namespace OCC {
+
+class NsfpDomainManager;
+class NsfpXpcHandler;
+
+class VfsNSFP : public Vfs
+{
+ Q_OBJECT
+
+public:
+ explicit VfsNSFP(QObject *parent = nullptr);
+ ~VfsNSFP() override;
+
+ [[nodiscard]] Mode mode() const override;
+
+ void stop() override;
+ void unregisterFolder() override;
+
+ [[nodiscard]] bool socketApiPinStateActionsShown() const override;
+
+ [[nodiscard]] Result createPlaceholder(const SyncFileItem &item) override;
+
+ [[nodiscard]] bool needsMetadataUpdate(const SyncFileItem &item) override;
+ [[nodiscard]] bool isDehydratedPlaceholder(const QString &filePath) override;
+ [[nodiscard]] LocalInfo statTypeVirtualFile(const std::filesystem::directory_entry &path, ItemType type) override;
+
+ [[nodiscard]] bool setPinState(const QString &relFilePath, PinState state) override;
+ [[nodiscard]] Optional pinState(const QString &relFilePath) override;
+ [[nodiscard]] AvailabilityResult availability(const QString &folderPath) override;
+
+public Q_SLOTS:
+ void fileStatusChanged(const QString &systemFileName, OCC::SyncFileStatus fileStatus) override;
+
+protected:
+ [[nodiscard]] Result updateMetadata(
+ const SyncFileItem &item, const QString &filePath, const QString &replacesFile) override;
+ void startImpl(const VfsSetupParams ¶ms) override;
+
+private:
+ /// Derives a stable domain identifier from account UUID and space ID.
+ [[nodiscard]] QString domainIdentifier() const;
+
+ std::unique_ptr _domainManager;
+ std::unique_ptr _xpcHandler;
+ QString _domainId;
+
+ /// Periodic timer that triggers sync cycles and metadata refresh so the
+ /// Finder view stays current with server-side changes (like iCloud/OneDrive).
+ QTimer _pollTimer;
+
+ /// In-memory pin state cache. Key: relative file path. Value: PinState.
+ /// For NSFP the journal does not store pin state natively, so we keep
+ /// an in-memory map that persists for the lifetime of the VFS instance.
+ QMap _pinStates;
+};
+
+class NsfpVfsPluginFactory : public QObject, public DefaultPluginFactory
+{
+ Q_OBJECT
+ Q_PLUGIN_METADATA(IID "eu.opencloud.PluginFactory" FILE "libsync/vfs/vfspluginmetadata.json")
+ Q_INTERFACES(OCC::PluginFactory)
+
+public:
+ Result prepare(const QString &path, const QUuid &accountUuid) const override;
+};
+
+} // namespace OCC
diff --git a/src/plugins/vfs/nsfp/vfs_nsfp.json b/src/plugins/vfs/nsfp/vfs_nsfp.json
new file mode 100644
index 0000000000..bee01e317b
--- /dev/null
+++ b/src/plugins/vfs/nsfp/vfs_nsfp.json
@@ -0,0 +1,4 @@
+{
+ "type": "vfs",
+ "version": "nsfp"
+}
diff --git a/src/plugins/vfs/nsfp/vfs_nsfp.mm b/src/plugins/vfs/nsfp/vfs_nsfp.mm
new file mode 100644
index 0000000000..48a6696b91
--- /dev/null
+++ b/src/plugins/vfs/nsfp/vfs_nsfp.mm
@@ -0,0 +1,1100 @@
+// 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);
+ }
+ });
+
+ _domainManager->addDomain(_domainId, displayName, [self](const QString &errorMessage) {
+ if (!self) {
+ return;
+ }
+
+ if (errorMessage.isEmpty()) {
+ QMetaObject::invokeMethod(self, [self]() {
+ if (self) {
+ qCInfo(lcVfsNSFP) << "NSFileProvider domain registered successfully";
+
+ // Start the XPC handler so the extension can reach us.
+ self->_xpcHandler = std::make_unique(self, self);
+ self->_xpcHandler->startListener();
+
+ // Write initial file metadata to the shared container
+ // so the extension can enumerate items immediately.
+ syncMetadataToSharedContainer(self->params().journal, 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);
+ }
+ });
+}
+
+Result NsfpVfsPluginFactory::prepare(const QString &path, const QUuid &accountUuid) const
+{
+ Q_UNUSED(path)
+ Q_UNUSED(accountUuid)
+ // No special preparation needed yet
+ return {};
+}
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 9f1afbe50f..2a66330fb8 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -57,4 +57,10 @@ configure_file(test_journal.db "${PROJECT_BINARY_DIR}/bin/test_journal.db" COPYO
opencloud_add_test(JobQueue)
+# macOS NSFileProvider VFS tests — require macOS 12+ (Darwin 21.x)
+if(APPLE AND CMAKE_SYSTEM_VERSION VERSION_GREATER_EQUAL "21.0")
+ opencloud_add_test(VfsNSFP)
+ opencloud_add_test(VfsNSFP_Integration)
+endif()
+
add_subdirectory(modeltests)
diff --git a/test/testvfsnsfp.cpp b/test/testvfsnsfp.cpp
new file mode 100644
index 0000000000..d91557a4e8
--- /dev/null
+++ b/test/testvfsnsfp.cpp
@@ -0,0 +1,308 @@
+// Unit tests for VfsNSFP -- macOS NSFileProvider VFS plugin core methods.
+
+// Use __APPLE__ (compiler-defined) instead of Q_OS_MACOS (Qt-defined via qglobal.h) because
+// this check appears before any Qt headers are included, so Q_OS_MACOS would never be defined.
+#if defined(__APPLE__)
+
+#include "common/syncjournaldb.h"
+#include "common/syncjournalfilerecord.h"
+#include "syncengine.h"
+#include "syncfileitem.h"
+#include "vfs/vfs.h"
+
+#include "testutils/syncenginetestutils.h"
+#include "testutils/testutils.h"
+
+#include
+#include
+#include
+
+using namespace OCC;
+using namespace Qt::Literals::StringLiterals;
+
+class TestVfsNSFP : public QObject
+{
+ Q_OBJECT
+
+private:
+ /// Helper: create a Vfs instance via the plugin manager and wire it up with
+ /// a journal and temp directory. The domain registration will fail (no daemon),
+ /// but params() will be usable for method-level unit tests.
+ struct VfsTestFixture
+ {
+ QTemporaryDir tempDir;
+ SyncJournalDb journal;
+ OCC::TestUtils::TestUtilsPrivate::AccountStateRaii accountState;
+ std::unique_ptr syncEngine;
+ std::unique_ptr vfs;
+ bool valid = false;
+
+ VfsTestFixture()
+ : journal(tempDir.path() + QStringLiteral("/sync.db"))
+ , accountState(OCC::TestUtils::createDummyAccount())
+ {
+ if (!tempDir.isValid()) {
+ return;
+ }
+
+ // Check if the NSFP plugin is available
+ if (!VfsPluginManager::instance().isVfsPluginAvailable(Vfs::Mode::MacOSNSFileProvider)) {
+ return;
+ }
+
+ // SyncEngine needs a localPath ending in '/'
+ const auto localPath = tempDir.path() + QStringLiteral("/syncroot/");
+ QDir().mkpath(localPath);
+
+ auto acc = accountState->account();
+ syncEngine = std::make_unique(acc, OCC::TestUtils::dummyDavUrl(), localPath, QStringLiteral("/"), &journal);
+
+ // Create the VFS plugin instance via the plugin manager
+ vfs.reset(VfsPluginManager::instance().createVfsFromPlugin(Vfs::Mode::MacOSNSFileProvider).release());
+ if (!vfs) {
+ return;
+ }
+
+ // Build VfsSetupParams and call start(). startImpl() will attempt domain
+ // registration which will fail without a real daemon, but params() will
+ // be available for subsequent method calls.
+ VfsSetupParams params(acc, OCC::TestUtils::dummyDavUrl(), QStringLiteral("test-space-id"), QStringLiteral("Test Folder"), syncEngine.get());
+ params.journal = &journal;
+
+ // We expect the error signal (no real domain daemon), but that's fine.
+ vfs->start(params);
+ valid = true;
+ }
+ };
+
+private Q_SLOTS:
+
+ void testModeString()
+ {
+ // Verify modeFromString("nsfp") returns MacOSNSFileProvider
+ const auto mode = Vfs::modeFromString(QStringLiteral("nsfp"));
+ QVERIFY(static_cast(mode));
+ QCOMPARE(*mode, Vfs::Mode::MacOSNSFileProvider);
+
+ // Verify enumToString(MacOSNSFileProvider) returns "nsfp"
+ const auto str = Utility::enumToString(Vfs::Mode::MacOSNSFileProvider);
+ QCOMPARE(str, QStringLiteral("nsfp"));
+ }
+
+ void testPluginConstruction()
+ {
+ // Verify VfsNSFP can be instantiated via plugin manager
+ if (!VfsPluginManager::instance().isVfsPluginAvailable(Vfs::Mode::MacOSNSFileProvider)) {
+ QSKIP("NSFP VFS plugin not available");
+ }
+
+ auto vfs = VfsPluginManager::instance().createVfsFromPlugin(Vfs::Mode::MacOSNSFileProvider);
+ QVERIFY(vfs);
+ QCOMPARE(vfs->mode(), Vfs::Mode::MacOSNSFileProvider);
+ QVERIFY(vfs->socketApiPinStateActionsShown());
+ }
+
+ void testPinStateRoundtrip()
+ {
+ VfsTestFixture fixture;
+ if (!fixture.valid) {
+ QSKIP("NSFP VFS plugin not available or fixture setup failed");
+ }
+ auto *vfs = fixture.vfs.get();
+
+ // AlwaysLocal
+ vfs->setPinState(QStringLiteral("testfile.txt"), PinState::AlwaysLocal);
+ auto ps = vfs->pinState(QStringLiteral("testfile.txt"));
+ QVERIFY(static_cast(ps));
+ QCOMPARE(*ps, PinState::AlwaysLocal);
+
+ // OnlineOnly
+ vfs->setPinState(QStringLiteral("testfile2.txt"), PinState::OnlineOnly);
+ ps = vfs->pinState(QStringLiteral("testfile2.txt"));
+ QVERIFY(static_cast(ps));
+ QCOMPARE(*ps, PinState::OnlineOnly);
+
+ // Unspecified -- default when no explicit state is set
+ ps = vfs->pinState(QStringLiteral("unknown.txt"));
+ QVERIFY(static_cast(ps));
+ QCOMPARE(*ps, PinState::Unspecified);
+ }
+
+ void testIsDehydratedPlaceholder_noJournalRecord()
+ {
+ VfsTestFixture fixture;
+ if (!fixture.valid) {
+ QSKIP("NSFP VFS plugin not available or fixture setup failed");
+ }
+
+ const auto syncRoot = fixture.tempDir.path() + QStringLiteral("/syncroot/");
+ const auto filePath = syncRoot + QStringLiteral("nonexistent.txt");
+ QVERIFY(!fixture.vfs->isDehydratedPlaceholder(filePath));
+ }
+
+ void testIsDehydratedPlaceholder_virtualFileRecord()
+ {
+ VfsTestFixture fixture;
+ if (!fixture.valid) {
+ QSKIP("NSFP VFS plugin not available or fixture setup failed");
+ }
+
+ // Insert a virtual file record into the journal
+ auto item = OCC::TestUtils::dummyItem(QStringLiteral("cloud-only.txt"));
+ item._type = ItemTypeVirtualFile;
+ item._etag = QStringLiteral("etag1");
+ item._fileId = "fileid1";
+ const auto record = SyncJournalFileRecord::fromSyncFileItem(item);
+ QVERIFY(fixture.journal.setFileRecord(record));
+
+ const auto syncRoot = fixture.tempDir.path() + QStringLiteral("/syncroot/");
+ const auto filePath = syncRoot + QStringLiteral("cloud-only.txt");
+ QVERIFY(fixture.vfs->isDehydratedPlaceholder(filePath));
+ }
+
+ void testIsDehydratedPlaceholder_localFileRecord()
+ {
+ VfsTestFixture fixture;
+ if (!fixture.valid) {
+ QSKIP("NSFP VFS plugin not available or fixture setup failed");
+ }
+
+ auto item = OCC::TestUtils::dummyItem(QStringLiteral("local-file.txt"));
+ item._type = ItemTypeFile;
+ item._etag = QStringLiteral("etag2");
+ item._fileId = "fileid2";
+ const auto record = SyncJournalFileRecord::fromSyncFileItem(item);
+ QVERIFY(fixture.journal.setFileRecord(record));
+
+ const auto syncRoot = fixture.tempDir.path() + QStringLiteral("/syncroot/");
+ const auto filePath = syncRoot + QStringLiteral("local-file.txt");
+ QVERIFY(!fixture.vfs->isDehydratedPlaceholder(filePath));
+ }
+
+ void testNeedsMetadataUpdate_differentEtag()
+ {
+ VfsTestFixture fixture;
+ if (!fixture.valid) {
+ QSKIP("NSFP VFS plugin not available or fixture setup failed");
+ }
+
+ // Insert a record with etag "old-etag"
+ auto item = OCC::TestUtils::dummyItem(QStringLiteral("meta-file.txt"));
+ item._etag = QStringLiteral("old-etag");
+ item._fileId = "fileid3";
+ item._modtime = 1000;
+ item._size = 500;
+ const auto record = SyncJournalFileRecord::fromSyncFileItem(item);
+ QVERIFY(fixture.journal.setFileRecord(record));
+
+ // Create a new item with different etag
+ auto newItem = OCC::TestUtils::dummyItem(QStringLiteral("meta-file.txt"));
+ newItem._etag = QStringLiteral("new-etag");
+ newItem._fileId = "fileid3";
+ newItem._modtime = 1000;
+ newItem._size = 500;
+
+ QVERIFY(fixture.vfs->needsMetadataUpdate(newItem));
+ }
+
+ void testNeedsMetadataUpdate_sameEtag()
+ {
+ VfsTestFixture fixture;
+ if (!fixture.valid) {
+ QSKIP("NSFP VFS plugin not available or fixture setup failed");
+ }
+
+ auto item = OCC::TestUtils::dummyItem(QStringLiteral("same-file.txt"));
+ item._etag = QStringLiteral("same-etag");
+ item._fileId = "fileid4";
+ item._modtime = 1000;
+ item._size = 500;
+ const auto record = SyncJournalFileRecord::fromSyncFileItem(item);
+ QVERIFY(fixture.journal.setFileRecord(record));
+
+ // Query with same metadata
+ auto queryItem = OCC::TestUtils::dummyItem(QStringLiteral("same-file.txt"));
+ queryItem._etag = QStringLiteral("same-etag");
+ queryItem._fileId = "fileid4";
+ queryItem._modtime = 1000;
+ queryItem._size = 500;
+
+ QVERIFY(!fixture.vfs->needsMetadataUpdate(queryItem));
+ }
+
+ void testDomainIdentifier()
+ {
+ // Verify the domain identifier derivation is stable: creating two fixtures
+ // with the same account UUID and space ID yields the same domain ID.
+ // Since VfsNSFP::domainIdentifier() is private, we verify indirectly that
+ // the VFS initializes correctly with a consistent mode.
+ VfsTestFixture fixture;
+ if (!fixture.valid) {
+ QSKIP("NSFP VFS plugin not available or fixture setup failed");
+ }
+
+ // Verify the VFS is in the correct mode after initialization
+ QCOMPARE(fixture.vfs->mode(), Vfs::Mode::MacOSNSFileProvider);
+
+ // Create a second fixture with the same account and verify consistency
+ VfsTestFixture fixture2;
+ if (!fixture2.valid) {
+ QSKIP("Second fixture failed to initialize");
+ }
+ QCOMPARE(fixture2.vfs->mode(), Vfs::Mode::MacOSNSFileProvider);
+ }
+
+ void testVolumeCheck()
+ {
+ // Test that startImpl() on a non-existent path handles the failure gracefully.
+ if (!VfsPluginManager::instance().isVfsPluginAvailable(Vfs::Mode::MacOSNSFileProvider)) {
+ QSKIP("NSFP VFS plugin not available");
+ }
+
+ auto accountState = OCC::TestUtils::createDummyAccount();
+ auto acc = accountState->account();
+
+ QTemporaryDir tempDir;
+ QVERIFY(tempDir.isValid());
+ SyncJournalDb journal(tempDir.path() + QStringLiteral("/sync.db"));
+
+ // Use a non-existent path as sync root
+ const auto nonExistentPath = tempDir.path() + QStringLiteral("/does-not-exist/syncroot/");
+
+ auto syncEngine = std::make_unique(acc, OCC::TestUtils::dummyDavUrl(), nonExistentPath, QStringLiteral("/"), &journal);
+
+ auto vfs = VfsPluginManager::instance().createVfsFromPlugin(Vfs::Mode::MacOSNSFileProvider);
+ QVERIFY(vfs);
+
+ QSignalSpy errorSpy(vfs.get(), &Vfs::error);
+
+ VfsSetupParams params(acc, OCC::TestUtils::dummyDavUrl(), QStringLiteral("test-space-id"), QStringLiteral("Test Folder"), syncEngine.get());
+ params.journal = &journal;
+
+ vfs->start(params);
+
+ // startImpl will fail the statfs call for non-existent path, but it
+ // just logs a warning and continues. The domain registration will also
+ // fail asynchronously. We verify the VFS was created without a crash.
+ QCOMPARE(vfs->mode(), Vfs::Mode::MacOSNSFileProvider);
+
+ // Process pending events to allow async error signals to arrive.
+ QCoreApplication::processEvents();
+ }
+};
+
+QTEST_GUILESS_MAIN(TestVfsNSFP)
+#include "testvfsnsfp.moc"
+
+#else
+// Non-macOS: provide an empty main so the build does not fail.
+#include
+class TestVfsNSFP : public QObject
+{
+ Q_OBJECT
+private Q_SLOTS:
+ void testSkipped() { QSKIP("VfsNSFP tests are macOS-only"); }
+};
+QTEST_GUILESS_MAIN(TestVfsNSFP)
+#include "testvfsnsfp.moc"
+#endif
diff --git a/test/testvfsnsfp_integration.cpp b/test/testvfsnsfp_integration.cpp
new file mode 100644
index 0000000000..f8f51dcb94
--- /dev/null
+++ b/test/testvfsnsfp_integration.cpp
@@ -0,0 +1,88 @@
+// Integration test stubs for VfsNSFP -- macOS NSFileProvider VFS plugin.
+//
+// These tests require a real macOS 12+ system with the NSFileProvider daemon
+// running. They are stubs that document the expected integration test scenarios
+// and ensure the test infrastructure is in place for future CI.
+
+// Use __APPLE__ (compiler-defined) instead of Q_OS_MACOS (Qt-defined via qglobal.h) because
+// this check appears before any Qt headers are included, so Q_OS_MACOS would never be defined.
+#if defined(__APPLE__)
+
+#include
+
+class TestVfsNSFPIntegration : public QObject
+{
+ Q_OBJECT
+
+private Q_SLOTS:
+
+ void testDomainRegistration()
+ {
+ QSKIP("Requires macOS 12+ with NSFileProvider daemon. "
+ "This test would register a domain via NsfpDomainManager::addDomain() "
+ "and verify it appears in [NSFileProviderManager getDomainsWithCompletionHandler:].");
+ }
+
+ void testPlaceholderAppearance()
+ {
+ QSKIP("Requires macOS 12+ with NSFileProvider daemon. "
+ "This test would call createPlaceholder() with a SyncFileItem and verify "
+ "the item appears as a cloud-only file in Finder via the File Provider framework.");
+ }
+
+ void testHydrationFlow()
+ {
+ QSKIP("Requires macOS 12+ with NSFileProvider daemon. "
+ "This test would open a placeholder file and verify that the File Provider "
+ "extension receives a fetchContents request via XPC and the file becomes "
+ "available locally with its full contents.");
+ }
+
+ void testEvictionFlow()
+ {
+ QSKIP("Requires macOS 12+ with NSFileProvider daemon. "
+ "This test would evict a hydrated file via NsfpDomainManager::evictItem() "
+ "and verify the file reverts to a dehydrated placeholder state, freeing "
+ "local disk space while keeping the cloud reference.");
+ }
+
+ void testUploadFlow()
+ {
+ QSKIP("Requires macOS 12+ with NSFileProvider daemon. "
+ "This test would copy a new file into the domain folder and verify that "
+ "the sync engine picks it up, uploads it to the server, and the file is "
+ "subsequently eligible for eviction.");
+ }
+
+ void testPinStateAlwaysLocal()
+ {
+ QSKIP("Requires macOS 12+ with NSFileProvider daemon. "
+ "This test would set a folder to PinState::AlwaysLocal and verify that "
+ "all child placeholder files are hydrated (downloaded) automatically, "
+ "ensuring the folder contents are always available offline.");
+ }
+
+ void testMigrationFromXattr()
+ {
+ QSKIP("Requires macOS 12+ with NSFileProvider daemon. "
+ "This test would set up a sync folder with existing xattr-based VFS "
+ "placeholders, switch to NSFP mode, and verify that the migration "
+ "completes without data loss and all files are accessible.");
+ }
+};
+
+QTEST_GUILESS_MAIN(TestVfsNSFPIntegration)
+#include "testvfsnsfp_integration.moc"
+
+#else
+// Non-macOS: provide an empty main so the build does not fail.
+#include
+class TestVfsNSFPIntegration : public QObject
+{
+ Q_OBJECT
+private Q_SLOTS:
+ void testSkipped() { QSKIP("VfsNSFP integration tests are macOS-only"); }
+};
+QTEST_GUILESS_MAIN(TestVfsNSFPIntegration)
+#include "testvfsnsfp_integration.moc"
+#endif