diff --git a/AGENTS.md b/AGENTS.md index 6d6f49480097b..739567d989a8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,7 @@ The following details are important when working on the desktop client on macOS. - The PIMPL pattern is an established convention in the Objective-C++ source code files under `src/gui/macOS`. - To abstract macOS and Objective-C specific APIs, prefer to use Qt and C++ types in public identifiers declared in headers. Use and prefer Objective-C or native macOS features only internally in implementations. This rule applies only to the code in `src/gui/macOS`, though. - When writing code in Swift, respect strict concurrency rules and Swift 6 compatibility. +- Manage memory explicitly and manually when writing or updating code located under `./src`. For example, do not use features like `__weak` from automatic reference counting in Objective-C because ARC is not used in this project. ### Tests diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+CustomActions.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+CustomActions.swift index e5838d51e29c6..3fd773ac94b5b 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+CustomActions.swift +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+CustomActions.swift @@ -83,7 +83,7 @@ extension FileProviderExtension: NSFileProviderCustomAction { } } - for try await result in group { + for try await _ in group { progress.completedUnitCount = 1 } } diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+NSXPCListenerDelegate.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+NSXPCListenerDelegate.swift index 29386f3236be8..b1f789b37cab2 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+NSXPCListenerDelegate.swift +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+NSXPCListenerDelegate.swift @@ -29,6 +29,9 @@ extension FileProviderExtension: NSXPCListenerDelegate { if let appService = remoteObjectProxy as? AppProtocol { logger.info("Succeeded to cast remote object proxy, adopting it!") self.app = appService + + // Initial status report as soon as the app service is available. + updatedSyncStateReporting(oldActions: Set()) } else { logger.error("Failed to cast remote object proxy to AppProtocol!") self.app = nil diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension.swift index cb28d82411643..0271d11a55c7e 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension.swift +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension.swift @@ -2,7 +2,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later import FileProvider -import NCDesktopClientSocketKit import NextcloudKit import NextcloudFileProviderKit import OSLog @@ -46,18 +45,6 @@ import OSLog var ignoredFiles: IgnoredFilesMatcher? lazy var ncKitBackground = NKBackground(nkCommonInstance: ncKit.nkCommonInstance) - lazy var socketClient: LocalSocketClient? = { - guard let containerUrl = FileManager.default.applicationGroupContainer() else { - logger.fault("Won't start socket client, no container URL available!") - return nil; - } - - let socketPath = containerUrl.appendingPathComponent("fps", conformingTo: .archive) - let lineProcessor = FileProviderSocketLineProcessor(delegate: self, log: log) - - return LocalSocketClient(socketPath: socketPath.path, lineProcessor: lineProcessor) - }() - var syncActions = Set() var errorActions = Set() var actionsLock = NSLock() @@ -101,7 +88,6 @@ import OSLog self.keychain = Keychain(log: log) super.init() - socketClient?.start() } func invalidate() { @@ -548,13 +534,6 @@ import OSLog fpManager.signalEnumerator(for: .workingSet, completionHandler: completionHandler) } - @objc func sendFileProviderDomainIdentifier() { - let command = "FILE_PROVIDER_DOMAIN_IDENTIFIER_REQUEST_REPLY" - let argument = domain.identifier.rawValue - let message = command + ":" + argument + "\n" - socketClient?.sendMessage(message) - } - private func signalEnumeratorAfterAccountSetup() { guard let fpManager = NSFileProviderManager(for: domain) else { logger.error("Could not get file provider manager for domain \(self.domain.displayName), cannot notify after account setup") diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderSocketLineProcessor.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderSocketLineProcessor.swift deleted file mode 100644 index c44a54dd65ebd..0000000000000 --- a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderSocketLineProcessor.swift +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later - -import Foundation -import NCDesktopClientSocketKit -import NextcloudFileProviderKit -import OSLog - -class FileProviderSocketLineProcessor: NSObject, LineProcessor { - var delegate: FileProviderExtension - let log: any FileProviderLogging - let logger: FileProviderLogger - - required init(delegate: FileProviderExtension, log: any FileProviderLogging) { - self.delegate = delegate - self.log = log - self.logger = FileProviderLogger(category: "FileProviderSocketLineProcessor", log: log) - } - - func process(_ line: String) { - if line.contains("~") { // We use this as the separator specifically in ACCOUNT_DETAILS - logger.debug("Processing file provider line with potentially sensitive user data") - } else { - logger.debug("Processing file provider line: \(line)") - } - - let splitLine = line.split(separator: ":", maxSplits: 1) - guard let commandSubsequence = splitLine.first else { - logger.error("Input line did not have a first element") - return - } - let command = String(commandSubsequence) - - logger.debug("Received command: \(command)") - if command == "SEND_FILE_PROVIDER_DOMAIN_IDENTIFIER" { - delegate.sendFileProviderDomainIdentifier() - } else if command == "ACCOUNT_NOT_AUTHENTICATED" { - delegate.removeAccountConfig() - } else if command == "ACCOUNT_DETAILS" { - guard let accountDetailsSubsequence = splitLine.last else { - logger.error("Account details did not have a first element") - return - } - let splitAccountDetails = accountDetailsSubsequence.split(separator: "~", maxSplits: 4) - - let userAgent = String(splitAccountDetails[0]) - let user = String(splitAccountDetails[1]) - let userId = String(splitAccountDetails[2]) - let serverUrl = String(splitAccountDetails[3]) - let password = String(splitAccountDetails[4]) - - delegate.setupDomainAccount( - user: user, - userId: userId, - serverUrl: serverUrl, - password: password, - userAgent: userAgent - ) - } else if command == "IGNORE_LIST" { - guard let ignoreListSubsequence = splitLine.last else { - logger.error("Ignore list missing contents!") - return - } - let ignoreList = ignoreListSubsequence.components(separatedBy: "_~IL$~_") - logger.debug("Applying \(ignoreList.count) ignore file patterns.") - delegate.ignoredFiles = IgnoredFilesMatcher(ignoreList: ignoreList, log: log) - } - } -} diff --git a/shell_integration/MacOSX/NextcloudIntegration/FinderSyncAppProtocol.h b/shell_integration/MacOSX/NextcloudIntegration/FinderSyncAppProtocol.h new file mode 100644 index 0000000000000..885de2b161746 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudIntegration/FinderSyncAppProtocol.h @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef FinderSyncAppProtocol_h +#define FinderSyncAppProtocol_h + +#import + +/** + * @brief The main app APIs exposed through XPC. + * + * This protocol is implemented by the main Nextcloud app and allows the FinderSync + * extension to query file statuses, menu items, and execute commands via XPC. + */ +@protocol FinderSyncAppProtocol + +/** + * @brief Retrieve the sync status for a file. + * @param path The absolute path to the file. + * @param completionHandler Callback with status string (e.g., "SYNC", "OK", "ERROR") or error. + */ +- (void)retrieveFileStatusForPath:(NSString *)path + completionHandler:(void(^)(NSString *status, NSError *error))completionHandler; + +/** + * @brief Retrieve the sync status for a folder. + * @param path The absolute path to the folder. + * @param completionHandler Callback with status string (e.g., "SYNC", "OK", "ERROR") or error. + */ +- (void)retrieveFolderStatusForPath:(NSString *)path + completionHandler:(void(^)(NSString *status, NSError *error))completionHandler; + +/** + * @brief Get localized strings for the FinderSync extension UI. + * @param completionHandler Callback with dictionary of string keys and localized values, or error. + */ +- (void)getLocalizedStringsWithCompletionHandler:(void(^)(NSDictionary *strings, NSError *error))completionHandler; + +/** + * @brief Get context menu items for the given paths. + * @param paths Array of absolute paths for which to get menu items. + * @param completionHandler Callback with array of menu item dictionaries (command, flags, text) or error. + */ +- (void)getMenuItemsForPaths:(NSArray *)paths + completionHandler:(void(^)(NSArray *menuItems, NSError *error))completionHandler; + +/** + * @brief Execute a menu command for the given paths. + * @param command The command identifier (e.g., "SHARE", "ACTIVITY"). + * @param paths Array of absolute paths on which to execute the command. + * @param completionHandler Callback with error if execution failed, or nil on success. + */ +- (void)executeMenuCommand:(NSString *)command + forPaths:(NSArray *)paths + completionHandler:(void(^)(NSError *error))completionHandler; + +@end + +#endif /* FinderSyncAppProtocol_h */ diff --git a/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSync.h b/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSync.h index a2ab3af7a5845..e511a0dd803d3 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSync.h +++ b/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSync.h @@ -6,14 +6,13 @@ #import #import -#import #import "SyncClient.h" -#import "FinderSyncSocketLineProcessor.h" + +@class FinderSyncXPCManager; @interface FinderSync : FIFinderSync -@property FinderSyncSocketLineProcessor *lineProcessor; -@property LocalSocketClient *localSocketClient; +@property FinderSyncXPCManager *xpcManager; @end diff --git a/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSync.m b/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSync.m index 7c389f8a47263..d3e57a7e4454f 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSync.m +++ b/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSync.m @@ -5,6 +5,7 @@ */ #import "FinderSync.h" +#import "FinderSyncXPCManager.h" @interface FinderSync() { @@ -25,8 +26,6 @@ - (instancetype)init if (self) { FIFinderSyncController *syncController = [FIFinderSyncController defaultController]; NSBundle *extBundle = [NSBundle bundleForClass:[self class]]; - // This was added to the bundle's Info.plist to get it from the build system - NSString *socketApiPrefix = [extBundle objectForInfoDictionaryKey:@"SocketApiPrefix"]; NSImage *ok = [extBundle imageForResource:@"ok.icns"]; NSImage *ok_swm = [extBundle imageForResource:@"ok_swm.icns"]; @@ -45,23 +44,11 @@ - (instancetype)init [syncController setBadgeImage:warning label:@"Ignored" forBadgeIdentifier:@"IGNORE+SWM"]; [syncController setBadgeImage:error label:@"Error" forBadgeIdentifier:@"ERROR+SWM"]; - NSURL *container = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:socketApiPrefix]; - NSURL *library = [container URLByAppendingPathComponent:@"Library" isDirectory:true]; - NSURL *applicationSupport = [library URLByAppendingPathComponent:@"Application Support" isDirectory:true]; - NSURL *socketPath = [applicationSupport URLByAppendingPathComponent:@"s" isDirectory:NO]; - - NSLog(@"Socket path: %@", socketPath.path); - - if (socketPath.path) { - self.lineProcessor = [[FinderSyncSocketLineProcessor alloc] initWithDelegate:self]; - self.localSocketClient = [[LocalSocketClient alloc] initWithSocketPath:socketPath.path - lineProcessor:self.lineProcessor]; - [self.localSocketClient start]; - [self.localSocketClient askOnSocket:@"" query:@"GET_STRINGS"]; - } else { - NSLog(@"No socket path. Not initiating local socket client."); - self.localSocketClient = nil; - } + // Initialize XPC manager instead of socket client + NSLog(@"Initializing FinderSync XPC manager"); + self.xpcManager = [[FinderSyncXPCManager alloc] initWithDelegate:self]; + [self.xpcManager start]; + [self.xpcManager askOnSocket:@"" query:@"GET_STRINGS"]; _registeredDirectories = NSMutableSet.set; _strings = NSMutableDictionary.dictionary; @@ -82,7 +69,7 @@ - (void)requestBadgeIdentifierForURL:(NSURL *)url } NSString* normalizedPath = [[url path] decomposedStringWithCanonicalMapping]; - [self.localSocketClient askForIcon:normalizedPath isDirectory:isDir]; + [self.xpcManager askForIcon:normalizedPath isDirectory:isDir]; } #pragma mark - Menu and toolbar item support @@ -110,10 +97,10 @@ - (void)waitForMenuToArrive - (NSMenu *)menuForMenuKind:(FIMenuKind)whichMenu { - if(![self.localSocketClient isConnected]) { + if(![self.xpcManager isConnected]) { return nil; } - + FIFinderSyncController *syncController = [FIFinderSyncController defaultController]; NSMutableSet *rootPaths = [[NSMutableSet alloc] init]; [syncController.directoryURLs enumerateObjectsUsingBlock: ^(id obj, BOOL *stop) { @@ -133,9 +120,9 @@ - (NSMenu *)menuForMenuKind:(FIMenuKind)whichMenu }]; NSString *paths = [self selectedPathsSeparatedByRecordSeparator]; - [self.localSocketClient askOnSocket:paths query:@"GET_MENU_ITEMS"]; - - // Since the LocalSocketClient communicates asynchronously. wait here until the menu + [self.xpcManager askOnSocket:paths query:@"GET_MENU_ITEMS"]; + + // Since the XPC communication is asynchronous, wait here until the menu // is delivered by another thread [self waitForMenuToArrive]; @@ -171,7 +158,7 @@ - (void)subMenuActionClicked:(id)sender { long idx = [(NSMenuItem*)sender tag]; NSString *command = [[_menuItems objectAtIndex:idx] valueForKey:@"command"]; NSString *paths = [self selectedPathsSeparatedByRecordSeparator]; - [self.localSocketClient askOnSocket:paths query:command]; + [self.xpcManager askOnSocket:paths query:command]; } #pragma mark - SyncClientProxyDelegate implementation diff --git a/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSyncSocketLineProcessor.h b/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSyncSocketLineProcessor.h deleted file mode 100644 index f43a64b760559..0000000000000 --- a/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSyncSocketLineProcessor.h +++ /dev/null @@ -1,26 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#import - -#import "SyncClient.h" - -#ifndef FinderSyncSocketLineProcessor_h -#define FinderSyncSocketLineProcessor_h - -/// This class is in charge of dispatching all work that must be done on the UI side of the extension. -/// Tasks are dispatched on the main UI thread for this reason. -/// -/// These tasks are parsed from byte data (UTF8 strings) acquired from the socket; look at the -/// LocalSocketClient for more detail on how data is read from and written to the socket. - -@interface FinderSyncSocketLineProcessor : NSObject - -@property(nonatomic, weak) id delegate; - -- (instancetype)initWithDelegate:(id)delegate; - -@end -#endif /* LineProcessor_h */ diff --git a/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSyncSocketLineProcessor.m b/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSyncSocketLineProcessor.m deleted file mode 100644 index 818666db51315..0000000000000 --- a/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSyncSocketLineProcessor.m +++ /dev/null @@ -1,93 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#import -#import "FinderSyncSocketLineProcessor.h" - -@implementation FinderSyncSocketLineProcessor - --(instancetype)initWithDelegate:(id)delegate -{ - NSLog(@"Init line processor with delegate."); - self = [super init]; - if (self) { - self.delegate = delegate; - } - return self; -} - --(void)process:(NSString*)line -{ - NSLog(@"Processing line: '%@'", line); - NSArray *split = [line componentsSeparatedByString:@":"]; - NSString *command = [split objectAtIndex:0]; - - NSLog(@"Command: %@", command); - - if([command isEqualToString:@"STATUS"]) { - NSString *result = [split objectAtIndex:1]; - NSArray *pathSplit = [split subarrayWithRange:NSMakeRange(2, [split count] - 2)]; // Get everything after location 2 - NSString *path = [pathSplit componentsJoinedByString:@":"]; - - dispatch_async(dispatch_get_main_queue(), ^{ - NSLog(@"Setting result %@ for path %@", result, path); - [self.delegate setResult:result forPath:path]; - }); - } else if([command isEqualToString:@"UPDATE_VIEW"]) { - NSString *path = [split objectAtIndex:1]; - - dispatch_async(dispatch_get_main_queue(), ^{ - NSLog(@"Re-fetching filename cache for path %@", path); - [self.delegate reFetchFileNameCacheForPath:path]; - }); - } else if([command isEqualToString:@"REGISTER_PATH"]) { - NSString *path = [split objectAtIndex:1]; - - dispatch_async(dispatch_get_main_queue(), ^{ - NSLog(@"Registering path %@", path); - [self.delegate registerPath:path]; - }); - } else if([command isEqualToString:@"UNREGISTER_PATH"]) { - NSString *path = [split objectAtIndex:1]; - - dispatch_async(dispatch_get_main_queue(), ^{ - NSLog(@"Unregistering path %@", path); - [self.delegate unregisterPath:path]; - }); - } else if([command isEqualToString:@"GET_STRINGS"]) { - // BEGIN and END messages, do nothing. - return; - } else if([command isEqualToString:@"STRING"]) { - NSString *key = [split objectAtIndex:1]; - NSString *value = [split objectAtIndex:2]; - - dispatch_async(dispatch_get_main_queue(), ^{ - NSLog(@"Setting string %@ to value %@", key, value); - [self.delegate setString:key value:value]; - }); - } else if([command isEqualToString:@"GET_MENU_ITEMS"]) { - if([[split objectAtIndex:1] isEqualToString:@"BEGIN"]) { - dispatch_async(dispatch_get_main_queue(), ^{ - NSLog(@"Resetting menu items."); - [self.delegate resetMenuItems]; - }); - } else { - NSLog(@"Emitting menu has completed signal."); - [self.delegate menuHasCompleted]; - } - } else if([command isEqualToString:@"MENU_ITEM"]) { - NSDictionary *item = @{@"command": [split objectAtIndex:1], @"flags": [split objectAtIndex:2], @"text": [split objectAtIndex:3]}; - - dispatch_async(dispatch_get_main_queue(), ^{ - NSLog(@"Adding menu item with command %@, flags %@, and text %@", [split objectAtIndex:1], [split objectAtIndex:2], [split objectAtIndex:3]); - [self.delegate addMenuItem:item]; - }); - } else { - // LOG UNKNOWN COMMAND - NSLog(@"Unknown command: %@", command); - } -} - -@end diff --git a/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSyncXPCManager.h b/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSyncXPCManager.h new file mode 100644 index 0000000000000..6135bb7afaeef --- /dev/null +++ b/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSyncXPCManager.h @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#import +#import "SyncClient.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * @brief Manages XPC communication between FinderSync extension and the main app. + * + * This class replaces the LocalSocketClient for FinderSync, providing XPC-based + * communication instead of UNIX sockets. It establishes a connection to the main app's + * XPC service and handles method calls in both directions. + */ +@interface FinderSyncXPCManager : NSObject + +/** + * @brief The delegate that receives callbacks from the main app via XPC. + */ +@property (nonatomic, weak) id delegate; + +/** + * @brief Initialize the XPC manager. + * @param delegate The delegate to receive XPC callbacks. + */ +- (instancetype)initWithDelegate:(id)delegate; + +/** + * @brief Start the XPC connection to the main app. + */ +- (void)start; + +/** + * @brief Check if the XPC connection is established. + * @return YES if connected, NO otherwise. + */ +- (BOOL)isConnected; + +/** + * @brief Request the badge icon for a file or directory. + * @param path The absolute path of the file/directory. + * @param isDirectory YES if the path is a directory, NO if it's a file. + */ +- (void)askForIcon:(NSString *)path isDirectory:(BOOL)isDirectory; + +/** + * @brief Send a query to the main app (generic method for menu commands, etc.). + * @param arg The argument string (usually file paths separated by record separator). + * @param query The command/query string (e.g., "GET_MENU_ITEMS", "SHARE", "LOCK_FILE"). + */ +- (void)askOnSocket:(NSString *)arg query:(NSString *)query; + +@end + +NS_ASSUME_NONNULL_END diff --git a/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSyncXPCManager.m b/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSyncXPCManager.m new file mode 100644 index 0000000000000..ca1be57f773a8 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSyncXPCManager.m @@ -0,0 +1,395 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#import "FinderSyncXPCManager.h" +#import "Services/FinderSyncProtocol.h" +#import "Services/FinderSyncAppProtocol.h" + +@interface FinderSyncXPCManager () +{ + NSXPCConnection *_connection; + id _appProxy; + BOOL _isConnected; + NSMutableDictionary *_statusCache; + dispatch_queue_t _connectionQueue; + NSUInteger _reconnectDelay; + BOOL _reconnectPending; // Flag to prevent concurrent reconnection attempts +} +@end + +@implementation FinderSyncXPCManager + +- (instancetype)initWithDelegate:(id)delegate +{ + self = [super init]; + if (self) { + _delegate = delegate; + _isConnected = NO; + _statusCache = [NSMutableDictionary dictionary]; + _connectionQueue = dispatch_queue_create("com.nextcloud.FinderSync.XPCQueue", DISPATCH_QUEUE_SERIAL); + _reconnectDelay = 1; + _reconnectPending = NO; + } + return self; +} + +- (void)dealloc +{ + [self invalidateConnection]; +} + +- (void)start +{ + NSLog(@"FinderSyncXPCManager: Starting XPC connection"); + [self establishConnection]; +} + +- (void)establishConnection +{ + dispatch_async(_connectionQueue, ^{ + if (self->_connection) { + NSLog(@"FinderSyncXPCManager: Connection already exists"); + return; + } + + // Get the bundle identifier to construct the service name + NSString *bundleId = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"PARENT_BUNDLE_ID"]; + if (!bundleId) { + // Fallback: try to construct it from the extension bundle ID + bundleId = [[NSBundle mainBundle] bundleIdentifier]; + // Remove the extension suffix (e.g., ".FinderSyncExt") + if ([bundleId hasSuffix:@".FinderSyncExt"]) { + bundleId = [bundleId substringToIndex:bundleId.length - 14]; + } + } + + NSString *serviceName = [NSString stringWithFormat:@"%@.FinderSyncService", bundleId]; + NSLog(@"FinderSyncXPCManager: Attempting to connect to service: %@", serviceName); + + NSXPCConnection *connection = [[NSXPCConnection alloc] initWithMachServiceName:serviceName + options:0]; + + if (!connection) { + NSLog(@"FinderSyncXPCManager: Failed to create XPC connection"); + [self scheduleReconnect]; + return; + } + + // Set up the connection interfaces + connection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(FinderSyncAppProtocol)]; + connection.exportedInterface = [NSXPCInterface interfaceWithProtocol:@protocol(FinderSyncProtocol)]; + connection.exportedObject = self; + + // Set up interruption and invalidation handlers + __weak FinderSyncXPCManager *weakSelf = self; + connection.interruptionHandler = ^{ + NSLog(@"FinderSyncXPCManager: XPC connection interrupted"); + FinderSyncXPCManager *strongSelf = weakSelf; + if (strongSelf) { + strongSelf->_isConnected = NO; + [strongSelf scheduleReconnect]; + } + }; + + connection.invalidationHandler = ^{ + NSLog(@"FinderSyncXPCManager: XPC connection invalidated"); + FinderSyncXPCManager *strongSelf = weakSelf; + if (strongSelf) { + strongSelf->_isConnected = NO; + strongSelf->_connection = nil; + strongSelf->_appProxy = nil; + [strongSelf scheduleReconnect]; + } + }; + + [connection resume]; + + self->_connection = connection; + self->_appProxy = [connection remoteObjectProxyWithErrorHandler:^(NSError *error) { + NSLog(@"FinderSyncXPCManager: Error getting remote proxy: %@", error.localizedDescription); + }]; + + self->_isConnected = YES; + self->_reconnectDelay = 1; // Reset reconnect delay on success + + NSLog(@"FinderSyncXPCManager: XPC connection established successfully"); + + // Request initial localized strings + [self requestLocalizedStrings]; + }); +} + +- (void)scheduleReconnect +{ + // All access to _reconnectPending happens on _connectionQueue (serial queue) + // so no additional synchronization needed + dispatch_async(_connectionQueue, ^{ + if (self->_reconnectPending) { + NSLog(@"FinderSyncXPCManager: Reconnect already pending, ignoring duplicate request"); + return; + } + + self->_reconnectPending = YES; + NSLog(@"FinderSyncXPCManager: Scheduling reconnect in %lu seconds", (unsigned long)self->_reconnectDelay); + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, self->_reconnectDelay * NSEC_PER_SEC), self->_connectionQueue, ^{ + self->_reconnectPending = NO; + [self establishConnection]; + }); + + // Exponential backoff: 1s, 2s, 4s, 8s (max) + self->_reconnectDelay = MIN(self->_reconnectDelay * 2, 8); + }); +} + +- (void)invalidateConnection +{ + dispatch_sync(_connectionQueue, ^{ + [self->_connection invalidate]; + self->_connection = nil; + self->_appProxy = nil; + self->_isConnected = NO; + }); +} + +- (BOOL)isConnected +{ + return _isConnected; +} + +- (void)requestLocalizedStrings +{ + if (!_appProxy) { + NSLog(@"FinderSyncXPCManager: Cannot request strings, no app proxy"); + return; + } + + [_appProxy getLocalizedStringsWithCompletionHandler:^(NSDictionary *strings, NSError *error) { + if (error) { + NSLog(@"FinderSyncXPCManager: Error getting localized strings: %@", error.localizedDescription); + return; + } + + NSLog(@"FinderSyncXPCManager: Received %lu localized strings", (unsigned long)strings.count); + + // Send strings to delegate + for (NSString *key in strings) { + NSString *value = strings[key]; + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self.delegate respondsToSelector:@selector(setString:value:)]) { + [self.delegate setString:key value:value]; + } + }); + } + }]; +} + +- (void)askForIcon:(NSString *)path isDirectory:(BOOL)isDirectory +{ + if (!_appProxy) { + NSLog(@"FinderSyncXPCManager: Cannot ask for icon, not connected"); + return; + } + + // Check cache first (thread-safe access) + NSString *cachedStatus; + @synchronized(_statusCache) { + cachedStatus = _statusCache[path]; + } + + if (cachedStatus) { + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self.delegate respondsToSelector:@selector(setResult:forPath:)]) { + [self.delegate setResult:cachedStatus forPath:path]; + } + }); + return; + } + + // Request from server + if (isDirectory) { + [_appProxy retrieveFolderStatusForPath:path completionHandler:^(NSString *status, NSError *error) { + if (error) { + NSLog(@"FinderSyncXPCManager: Error retrieving folder status for %@: %@", path, error.localizedDescription); + return; + } + + if (status) { + // Thread-safe cache update + @synchronized(self->_statusCache) { + self->_statusCache[path] = status; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self.delegate respondsToSelector:@selector(setResult:forPath:)]) { + [self.delegate setResult:status forPath:path]; + } + }); + } + }]; + } else { + [_appProxy retrieveFileStatusForPath:path completionHandler:^(NSString *status, NSError *error) { + if (error) { + NSLog(@"FinderSyncXPCManager: Error retrieving file status for %@: %@", path, error.localizedDescription); + return; + } + + if (status) { + // Thread-safe cache update + @synchronized(self->_statusCache) { + self->_statusCache[path] = status; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self.delegate respondsToSelector:@selector(setResult:forPath:)]) { + [self.delegate setResult:status forPath:path]; + } + }); + } + }]; + } +} + +- (void)askOnSocket:(NSString *)arg query:(NSString *)query +{ + if (!_appProxy) { + NSLog(@"FinderSyncXPCManager: Cannot send query, not connected"); + return; + } + + NSLog(@"FinderSyncXPCManager: Query: %@ for: %@", query, arg); + + if ([query isEqualToString:@"GET_MENU_ITEMS"]) { + // Parse paths from arg (record separator 0x1e) + NSArray *paths = [arg componentsSeparatedByString:@"\x1e"]; + + [_appProxy getMenuItemsForPaths:paths completionHandler:^(NSArray *menuItems, NSError *error) { + if (error) { + NSLog(@"FinderSyncXPCManager: Error getting menu items: %@", error.localizedDescription); + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self.delegate respondsToSelector:@selector(menuHasCompleted)]) { + [self.delegate menuHasCompleted]; + } + }); + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self.delegate respondsToSelector:@selector(resetMenuItems)]) { + [self.delegate resetMenuItems]; + } + + for (NSDictionary *item in menuItems) { + if ([self.delegate respondsToSelector:@selector(addMenuItem:)]) { + [self.delegate addMenuItem:item]; + } + } + + if ([self.delegate respondsToSelector:@selector(menuHasCompleted)]) { + [self.delegate menuHasCompleted]; + } + }); + }]; + } else if ([query isEqualToString:@"GET_STRINGS"]) { + [self requestLocalizedStrings]; + } else { + // Execute menu command + NSArray *paths = [arg componentsSeparatedByString:@"\x1e"]; + [_appProxy executeMenuCommand:query forPaths:paths completionHandler:^(NSError *error) { + if (error) { + NSLog(@"FinderSyncXPCManager: Error executing command %@: %@", query, error.localizedDescription); + } else { + NSLog(@"FinderSyncXPCManager: Command %@ executed successfully", query); + } + }]; + } +} + +#pragma mark - FinderSyncProtocol Implementation + +- (void)registerPath:(NSString *)path +{ + NSLog(@"FinderSyncXPCManager: Registering path: %@", path); + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self.delegate respondsToSelector:@selector(registerPath:)]) { + [self.delegate registerPath:path]; + } + }); +} + +- (void)unregisterPath:(NSString *)path +{ + NSLog(@"FinderSyncXPCManager: Unregistering path: %@", path); + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self.delegate respondsToSelector:@selector(unregisterPath:)]) { + [self.delegate unregisterPath:path]; + } + }); +} + +- (void)updateViewAtPath:(NSString *)path +{ + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self.delegate respondsToSelector:@selector(reFetchFileNameCacheForPath:)]) { + [self.delegate reFetchFileNameCacheForPath:path]; + } + }); +} + +- (void)setStatusResult:(NSString *)status forPath:(NSString *)path +{ + // Thread-safe cache update + @synchronized(_statusCache) { + _statusCache[path] = status; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self.delegate respondsToSelector:@selector(setResult:forPath:)]) { + [self.delegate setResult:status forPath:path]; + } + }); +} + +- (void)setLocalizedString:(NSString *)value forKey:(NSString *)key +{ + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self.delegate respondsToSelector:@selector(setString:value:)]) { + [self.delegate setString:key value:value]; + } + }); +} + +- (void)resetMenuItems +{ + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self.delegate respondsToSelector:@selector(resetMenuItems)]) { + [self.delegate resetMenuItems]; + } + }); +} + +- (void)addMenuItemWithCommand:(NSString *)command flags:(NSString *)flags text:(NSString *)text +{ + NSDictionary *item = @{ + @"command": command, + @"flags": flags, + @"text": text + }; + + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self.delegate respondsToSelector:@selector(addMenuItem:)]) { + [self.delegate addMenuItem:item]; + } + }); +} + +- (void)menuItemsComplete +{ + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self.delegate respondsToSelector:@selector(menuHasCompleted)]) { + [self.delegate menuHasCompleted]; + } + }); +} + +@end diff --git a/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/Services/FinderSyncAppProtocol.h b/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/Services/FinderSyncAppProtocol.h new file mode 100644 index 0000000000000..9c99e43ae7c16 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/Services/FinderSyncAppProtocol.h @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef FinderSyncAppProtocol_h +#define FinderSyncAppProtocol_h + +#import + +/** + * @brief The main app APIs exposed through XPC for FinderSync extension. + * + * This protocol is implemented by the main application and allows the FinderSync + * extension to request information and execute commands via XPC. + */ +@protocol FinderSyncAppProtocol + +/** + * @brief Retrieve the sync status for a specific file. + * @param path The absolute path of the file. + * @param completionHandler Called with the status string (e.g., "SYNC", "OK", "ERROR") or error. + */ +- (void)retrieveFileStatusForPath:(NSString *)path + completionHandler:(void(^)(NSString *status, NSError *error))completionHandler; + +/** + * @brief Retrieve the sync status for a specific folder. + * @param path The absolute path of the folder. + * @param completionHandler Called with the status string or error. + */ +- (void)retrieveFolderStatusForPath:(NSString *)path + completionHandler:(void(^)(NSString *status, NSError *error))completionHandler; + +/** + * @brief Get all localized strings for the FinderSync extension UI. + * @param completionHandler Called with a dictionary of key-value string pairs or error. + */ +- (void)getLocalizedStringsWithCompletionHandler:(void(^)(NSDictionary *strings, NSError *error))completionHandler; + +/** + * @brief Get context menu items for the specified file/folder paths. + * @param paths Array of absolute paths for which to generate menu items. + * @param completionHandler Called with an array of menu item dictionaries (command, flags, text) or error. + */ +- (void)getMenuItemsForPaths:(NSArray *)paths + completionHandler:(void(^)(NSArray *menuItems, NSError *error))completionHandler; + +/** + * @brief Execute a menu command for the specified paths. + * @param command The command identifier (e.g., "SHARE", "LOCK_FILE", "ACTIVITY"). + * @param paths Array of absolute paths to which the command applies. + * @param completionHandler Called when the command completes, with error if any. + */ +- (void)executeMenuCommand:(NSString *)command + forPaths:(NSArray *)paths + completionHandler:(void(^)(NSError *error))completionHandler; + +@end + +#endif /* FinderSyncAppProtocol_h */ diff --git a/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/Services/FinderSyncProtocol.h b/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/Services/FinderSyncProtocol.h new file mode 100644 index 0000000000000..17c1f9faad161 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/Services/FinderSyncProtocol.h @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef FinderSyncProtocol_h +#define FinderSyncProtocol_h + +#import + +/** + * @brief The FinderSync extension APIs exposed through XPC. + * + * This protocol is implemented by the FinderSync extension and allows the main app + * to communicate with the extension via XPC instead of UNIX sockets. + */ +@protocol FinderSyncProtocol + +/** + * @brief Register a path for sync monitoring. + * @param path The absolute path to register for monitoring. + */ +- (void)registerPath:(NSString *)path; + +/** + * @brief Unregister a path from sync monitoring. + * @param path The absolute path to unregister. + */ +- (void)unregisterPath:(NSString *)path; + +/** + * @brief Notify the extension to update the view for a given path. + * @param path The absolute path where the view should be refreshed. + */ +- (void)updateViewAtPath:(NSString *)path; + +/** + * @brief Set the sync status result for a specific file or folder. + * @param status The status string (e.g., "SYNC", "OK", "ERROR", "OK+SWM"). + * @param path The absolute path of the file or folder. + */ +- (void)setStatusResult:(NSString *)status forPath:(NSString *)path; + +/** + * @brief Set a localized string value for a given key. + * @param value The localized string value. + * @param key The string key (e.g., "CONTEXT_MENU_TITLE"). + */ +- (void)setLocalizedString:(NSString *)value forKey:(NSString *)key; + +/** + * @brief Reset all menu items (clears the current menu item list). + */ +- (void)resetMenuItems; + +/** + * @brief Add a menu item to the context menu. + * @param command The command identifier (e.g., "SHARE", "LOCK_FILE"). + * @param flags The menu item flags (empty string for enabled, "d" for disabled). + * @param text The menu item display text. + */ +- (void)addMenuItemWithCommand:(NSString *)command + flags:(NSString *)flags + text:(NSString *)text; + +/** + * @brief Signal that all menu items have been sent (marks menu as complete). + */ +- (void)menuItemsComplete; + +@end + +#endif /* FinderSyncProtocol_h */ diff --git a/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj b/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj index deb1e17c48f5b..f2405785b75f0 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj +++ b/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 5352B36C29DC44B50011CE03 /* FileProviderExtension+Thumbnailing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5352B36B29DC44B50011CE03 /* FileProviderExtension+Thumbnailing.swift */; }; 53651E442BBC0CA300ECAC29 /* SuggestionsTextFieldKit in Frameworks */ = {isa = PBXBuildFile; productRef = 53651E432BBC0CA300ECAC29 /* SuggestionsTextFieldKit */; }; 53651E462BBC0D9500ECAC29 /* ShareeSuggestionsDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53651E452BBC0D9500ECAC29 /* ShareeSuggestionsDataSource.swift */; }; - 536EFBF7295CF58100F4CB13 /* FileProviderSocketLineProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536EFBF6295CF58100F4CB13 /* FileProviderSocketLineProcessor.swift */; }; 5374FD442B95EE1400C78D54 /* ShareController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5374FD432B95EE1400C78D54 /* ShareController.swift */; }; 537630912B85F4980026BFAB /* ShareViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 537630902B85F4980026BFAB /* ShareViewController.xib */; }; 537630932B85F4B00026BFAB /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537630922B85F4B00026BFAB /* ShareViewController.swift */; }; @@ -35,7 +34,6 @@ 53903D312956173F00D0B308 /* NCDesktopClientSocketKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 53903D0C2956164F00D0B308 /* NCDesktopClientSocketKit.framework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 53903D352956184400D0B308 /* LocalSocketClient.h in Headers */ = {isa = PBXBuildFile; fileRef = 539158B127BE891500816F56 /* LocalSocketClient.h */; settings = {ATTRIBUTES = (Public, ); }; }; 53903D37295618A400D0B308 /* LineProcessor.h in Headers */ = {isa = PBXBuildFile; fileRef = 53903D36295618A400D0B308 /* LineProcessor.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 539158AC27BE71A900816F56 /* FinderSyncSocketLineProcessor.m in Sources */ = {isa = PBXBuildFile; fileRef = 539158AB27BE71A900816F56 /* FinderSyncSocketLineProcessor.m */; }; 53B979812B84C81F002DA742 /* DocumentActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53B979802B84C81F002DA742 /* DocumentActionViewController.swift */; }; 53D666612B70C9A70042C03D /* FileProviderDomainDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53D666602B70C9A70042C03D /* FileProviderDomainDefaults.swift */; }; 53ED473029C9CE0B00795DB1 /* FileProviderExtension+NSFileProviderServicing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53ED472F29C9CE0B00795DB1 /* FileProviderExtension+NSFileProviderServicing.swift */; }; @@ -57,6 +55,7 @@ AAA6F17D2F1E647D00FFB2BA /* FileProviderExtension+NSFileProviderServiceSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA6F17C2F1E647800FFB2BA /* FileProviderExtension+NSFileProviderServiceSource.swift */; }; AAA6F17F2F1E64F500FFB2BA /* FileProviderExtension+NSXPCListenerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA6F17E2F1E64F100FFB2BA /* FileProviderExtension+NSXPCListenerDelegate.swift */; }; AAA6F1812F1E653300FFB2BA /* FileProviderExtension+ClientCommunicationProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA6F1802F1E652E00FFB2BA /* FileProviderExtension+ClientCommunicationProtocol.swift */; }; + AABBD8972F3CB64800662E00 /* FinderSyncXPCManager.m in Sources */ = {isa = PBXBuildFile; fileRef = AABBD8962F3CB64800662E00 /* FinderSyncXPCManager.m */; }; AAC00D2A2E37B29D006010FE /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AAC00D292E37B29D006010FE /* Localizable.xcstrings */; }; AAF19A682E8D5B4E005FE5B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AAF19A672E8D5B4E005FE5B0 /* Assets.xcassets */; }; AAF19A7A2E8D5B63005FE5B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AAF19A792E8D5B63005FE5B0 /* Assets.xcassets */; }; @@ -171,7 +170,6 @@ 5350E4EA2B0C9CE100F276CB /* FileProviderExt-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FileProviderExt-Bridging-Header.h"; sourceTree = ""; }; 5352B36B29DC44B50011CE03 /* FileProviderExtension+Thumbnailing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileProviderExtension+Thumbnailing.swift"; sourceTree = ""; }; 53651E452BBC0D9500ECAC29 /* ShareeSuggestionsDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareeSuggestionsDataSource.swift; sourceTree = ""; }; - 536EFBF6295CF58100F4CB13 /* FileProviderSocketLineProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderSocketLineProcessor.swift; sourceTree = ""; }; 5374FD432B95EE1400C78D54 /* ShareController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareController.swift; sourceTree = ""; }; 537630902B85F4980026BFAB /* ShareViewController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ShareViewController.xib; sourceTree = ""; }; 537630922B85F4B00026BFAB /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; @@ -187,9 +185,7 @@ 53903D0C2956164F00D0B308 /* NCDesktopClientSocketKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = NCDesktopClientSocketKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 53903D0E2956164F00D0B308 /* NCDesktopClientSocketKit.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NCDesktopClientSocketKit.h; sourceTree = ""; }; 53903D36295618A400D0B308 /* LineProcessor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LineProcessor.h; sourceTree = ""; }; - 539158A927BE606500816F56 /* FinderSyncSocketLineProcessor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FinderSyncSocketLineProcessor.h; sourceTree = ""; }; 539158AA27BE67CC00816F56 /* SyncClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SyncClient.h; sourceTree = ""; }; - 539158AB27BE71A900816F56 /* FinderSyncSocketLineProcessor.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FinderSyncSocketLineProcessor.m; sourceTree = ""; }; 539158B127BE891500816F56 /* LocalSocketClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LocalSocketClient.h; sourceTree = ""; }; 539158B227BEC98A00816F56 /* LocalSocketClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LocalSocketClient.m; sourceTree = ""; }; 53B9797E2B84C81F002DA742 /* FileProviderUIExt.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FileProviderUIExt.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -218,6 +214,10 @@ AAA6F17E2F1E64F100FFB2BA /* FileProviderExtension+NSXPCListenerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileProviderExtension+NSXPCListenerDelegate.swift"; sourceTree = ""; }; AAA6F1802F1E652E00FFB2BA /* FileProviderExtension+ClientCommunicationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileProviderExtension+ClientCommunicationProtocol.swift"; sourceTree = ""; }; AAAF76222F1A83A900BD0F0D /* AppProtocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppProtocol.h; sourceTree = ""; }; + AABBD8952F3CB64800662E00 /* FinderSyncXPCManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FinderSyncXPCManager.h; sourceTree = ""; }; + AABBD8962F3CB64800662E00 /* FinderSyncXPCManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FinderSyncXPCManager.m; sourceTree = ""; }; + AABBD8982F3CB65800662E00 /* FinderSyncAppProtocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FinderSyncAppProtocol.h; sourceTree = ""; }; + AABBD8992F3CB65800662E00 /* FinderSyncProtocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FinderSyncProtocol.h; sourceTree = ""; }; AAC00D292E37B29D006010FE /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; AAF19A672E8D5B4E005FE5B0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; AAF19A792E8D5B63005FE5B0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -356,7 +356,6 @@ 53ED472F29C9CE0B00795DB1 /* FileProviderExtension+NSFileProviderServicing.swift */, 530429972DD44226004BB598 /* FileProviderExtension+CustomActions.swift */, 5352B36B29DC44B50011CE03 /* FileProviderExtension+Thumbnailing.swift */, - 536EFBF6295CF58100F4CB13 /* FileProviderSocketLineProcessor.swift */, AA02B2AA2E7048C600C72B34 /* Keychain.swift */, 538E397227F4765000FA63D5 /* Info.plist */, 5350E4EA2B0C9CE100F276CB /* FileProviderExt-Bridging-Header.h */, @@ -412,6 +411,15 @@ path = Authentication; sourceTree = ""; }; + AABBD89A2F3CB65800662E00 /* Services */ = { + isa = PBXGroup; + children = ( + AABBD8982F3CB65800662E00 /* FinderSyncAppProtocol.h */, + AABBD8992F3CB65800662E00 /* FinderSyncProtocol.h */, + ); + path = Services; + sourceTree = ""; + }; C2B573941B1CD88000303B36 = { isa = PBXGroup; children = ( @@ -460,12 +468,13 @@ C2B573D81B1CD9CE00303B36 /* FinderSyncExt */ = { isa = PBXGroup; children = ( - 539158AA27BE67CC00816F56 /* SyncClient.h */, C2B573DC1B1CD9CE00303B36 /* FinderSync.h */, C2B573DD1B1CD9CE00303B36 /* FinderSync.m */, - 539158A927BE606500816F56 /* FinderSyncSocketLineProcessor.h */, - 539158AB27BE71A900816F56 /* FinderSyncSocketLineProcessor.m */, + AABBD8952F3CB64800662E00 /* FinderSyncXPCManager.h */, + AABBD8962F3CB64800662E00 /* FinderSyncXPCManager.m */, + AABBD89A2F3CB65800662E00 /* Services */, C2B573D91B1CD9CE00303B36 /* Supporting Files */, + 539158AA27BE67CC00816F56 /* SyncClient.h */, ); path = FinderSyncExt; sourceTree = ""; @@ -873,7 +882,6 @@ AAA6F1792F1E608000FFB2BA /* FileProviderExtension+ChangeNotificationInterface.swift in Sources */, 538E396D27F4765000FA63D5 /* FileProviderExtension.swift in Sources */, AA7F17E72E7038370000E928 /* NSError+FileProviderErrorCode.swift in Sources */, - 536EFBF7295CF58100F4CB13 /* FileProviderSocketLineProcessor.swift in Sources */, AA02B2AB2E7048C800C72B34 /* Keychain.swift in Sources */, 537630972B860D920026BFAB /* FPUIExtensionService.swift in Sources */, 537630952B860D560026BFAB /* FPUIExtensionServiceSource.swift in Sources */, @@ -925,7 +933,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 539158AC27BE71A900816F56 /* FinderSyncSocketLineProcessor.m in Sources */, + AABBD8972F3CB64800662E00 /* FinderSyncXPCManager.m in Sources */, C2B573DE1B1CD9CE00303B36 /* FinderSync.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 42f4f740055f7..97d3e83411e69 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "61feeb4866ca418188987b3ae0c9a2032cac13e1662ac72932283925957aa395", + "originHash" : "c39c61ca9816f7a9684ea31d14365bba0d879077e83a1dcd590384479d73fa85", "pins" : [ { "identity" : "alamofire", diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index b6d636f47ac1c..4e0949a01edac 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -295,10 +295,12 @@ IF( APPLE ) if (BUILD_FILE_PROVIDER_MODULE) list(APPEND client_SRCS - # Symlinks to files in shell_integration/MacOSX/NextcloudIntegration/ + # XPC Protocol files from shell_integration/MacOSX/NextcloudIntegration/ macOS/AppProtocol.h macOS/ClientCommunicationProtocol.h - # End of symlink files + macOS/FinderSyncProtocol.h + macOS/FinderSyncAppProtocol.h + # End of XPC protocol files macOS/fileprovider.h macOS/fileprovider_mac.mm macOS/fileproviderdomainmanager.h @@ -313,17 +315,16 @@ IF( APPLE ) macOS/fileproviderservice.mm macOS/fileprovidersettingscontroller.h macOS/fileprovidersettingscontroller_mac.mm - macOS/fileprovidersocketcontroller.h - macOS/fileprovidersocketcontroller.cpp - macOS/fileprovidersocketserver.h - macOS/fileprovidersocketserver.cpp - macOS/fileprovidersocketserver_mac.mm macOS/fileproviderutils.h macOS/fileproviderutils_mac.mm macOS/fileproviderxpc.h macOS/fileproviderxpc_mac.mm macOS/fileproviderxpc_mac_utils.h macOS/fileproviderxpc_mac_utils.mm + macOS/findersyncxpc.h + macOS/findersyncxpc_mac.mm + macOS/findersyncservice.h + macOS/findersyncservice.mm macOS/progressobserver.h macOS/progressobserver.m) endif() diff --git a/src/gui/application.cpp b/src/gui/application.cpp index ae14bc11a3ff5..f58194439db27 100644 --- a/src/gui/application.cpp +++ b/src/gui/application.cpp @@ -39,6 +39,8 @@ #include "shellextensionsserver.h" #elif defined(Q_OS_MACOS) #include "macOS/fileprovider.h" +#include "macOS/findersyncxpc.h" +#include "macOS/findersyncservice.h" #endif #include @@ -215,6 +217,13 @@ ownCloudGui *Application::gui() const return _gui; } +#if defined(Q_OS_MACOS) && defined(BUILD_FILE_PROVIDER_MODULE) +Mac::FinderSyncXPC *Application::finderSyncXPC() const +{ + return _finderSyncXPC.get(); +} +#endif + Application::Application(int &argc, char **argv) : QApplication{argc, argv} , _gui(nullptr) @@ -469,6 +478,16 @@ Application::Application(int &argc, char **argv) #if defined(BUILD_FILE_PROVIDER_MODULE) Mac::FileProvider::instance(); + Mac::FileProvider::instance()->configureXPC(); + + // Initialize FinderSync XPC + _finderSyncService = std::make_unique(this); + _finderSyncService->setSocketApi(FolderMan::instance()->socketApi()); + + _finderSyncXPC = std::make_unique(this); + _finderSyncXPC->startListener(_finderSyncService.get()); + + qCInfo(lcApplication) << "FinderSync XPC initialized"; #endif } diff --git a/src/gui/application.h b/src/gui/application.h index cd5386b73503a..4157b3802f8d6 100644 --- a/src/gui/application.h +++ b/src/gui/application.h @@ -34,6 +34,13 @@ class Folder; class ShellExtensionsServer; class SslErrorDialog; +#if defined(Q_OS_MACOS) && defined(BUILD_FILE_PROVIDER_MODULE) +namespace Mac { +class FinderSyncXPC; +class FinderSyncService; +} +#endif + /** * @brief The Application class * @ingroup gui @@ -61,6 +68,10 @@ class Application : public QApplication [[nodiscard]] ownCloudGui *gui() const; +#if defined(Q_OS_MACOS) && defined(BUILD_FILE_PROVIDER_MODULE) + [[nodiscard]] Mac::FinderSyncXPC *finderSyncXPC() const; +#endif + bool event(QEvent *event) override; public slots: @@ -147,6 +158,10 @@ protected slots: #if defined(Q_OS_WIN) QScopedPointer _shellExtensionsServer; #endif +#if defined(Q_OS_MACOS) && defined(BUILD_FILE_PROVIDER_MODULE) + std::unique_ptr _finderSyncXPC; + std::unique_ptr _finderSyncService; +#endif }; } // namespace OCC diff --git a/src/gui/macOS/FinderSyncAppProtocol.h b/src/gui/macOS/FinderSyncAppProtocol.h new file mode 120000 index 0000000000000..da907e49999f1 --- /dev/null +++ b/src/gui/macOS/FinderSyncAppProtocol.h @@ -0,0 +1 @@ +../../../shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/Services/FinderSyncAppProtocol.h \ No newline at end of file diff --git a/src/gui/macOS/FinderSyncProtocol.h b/src/gui/macOS/FinderSyncProtocol.h new file mode 120000 index 0000000000000..d923e8112b2dc --- /dev/null +++ b/src/gui/macOS/FinderSyncProtocol.h @@ -0,0 +1 @@ +../../../shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/Services/FinderSyncProtocol.h \ No newline at end of file diff --git a/src/gui/macOS/fileprovider.h b/src/gui/macOS/fileprovider.h index d1dd9bc5f346d..922731b4a67da 100644 --- a/src/gui/macOS/fileprovider.h +++ b/src/gui/macOS/fileprovider.h @@ -9,7 +9,6 @@ #include "fileproviderdomainmanager.h" #include "fileproviderservice.h" -#include "fileprovidersocketserver.h" #include "fileproviderxpc.h" namespace OCC { @@ -32,12 +31,10 @@ class FileProvider : public QObject void configureXPC(); [[nodiscard]] FileProviderXPC *xpc() const; [[nodiscard]] FileProviderDomainManager *domainManager() const; - [[nodiscard]] FileProviderSocketServer *socketServer() const; [[nodiscard]] FileProviderService *service() const; private: std::unique_ptr _domainManager; - std::unique_ptr _socketServer; std::unique_ptr _xpc; std::unique_ptr _service; diff --git a/src/gui/macOS/fileprovider_mac.mm b/src/gui/macOS/fileprovider_mac.mm index 33a99d68b3707..c0ddf96c71511 100644 --- a/src/gui/macOS/fileprovider_mac.mm +++ b/src/gui/macOS/fileprovider_mac.mm @@ -32,12 +32,6 @@ _domainManager->start(); } - _socketServer = std::make_unique(this); - - if (_socketServer) { - qCDebug(lcMacFileProvider) << "Initialised file provider socket server."; - } - _service = std::make_unique(this); if (_service) { @@ -82,11 +76,6 @@ return _domainManager.get(); } -FileProviderSocketServer *FileProvider::socketServer() const -{ - return _socketServer.get(); -} - FileProviderService *FileProvider::service() const { return _service.get(); diff --git a/src/gui/macOS/fileprovidersocketcontroller.cpp b/src/gui/macOS/fileprovidersocketcontroller.cpp deleted file mode 100644 index 38ebdf3b0bb90..0000000000000 --- a/src/gui/macOS/fileprovidersocketcontroller.cpp +++ /dev/null @@ -1,262 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#include "fileprovidersocketcontroller.h" - -#include -#include - -#include "csync/csync_exclude.h" -#include "libsync/configfile.h" - -#include "accountmanager.h" -#include "common/utility.h" -#include "fileproviderdomainmanager.h" - -namespace OCC { - -namespace Mac { - -Q_LOGGING_CATEGORY(lcFileProviderSocketController, "nextcloud.gui.macos.fileprovider.socketcontroller", QtInfoMsg) - -FileProviderSocketController::FileProviderSocketController(QLocalSocket * const socket, QObject * const parent) - : QObject{parent} - , _socket(socket) -{ - connect(socket, &QLocalSocket::readyRead, - this, &FileProviderSocketController::slotReadyRead); - connect(socket, &QLocalSocket::disconnected, - this, &FileProviderSocketController::slotOnDisconnected); - connect(socket, &QLocalSocket::destroyed, - this, &FileProviderSocketController::slotSocketDestroyed); -} - -void FileProviderSocketController::slotOnDisconnected() -{ - qCInfo(lcFileProviderSocketController) << "File provider socket disconnected"; - _socket->deleteLater(); -} - -void FileProviderSocketController::slotSocketDestroyed(const QObject * const object) -{ - Q_UNUSED(object) - qCInfo(lcFileProviderSocketController) << "File provider socket object has been destroyed, destroying controller"; - Q_EMIT socketDestroyed(_socket); -} - -void FileProviderSocketController::slotReadyRead() -{ - Q_ASSERT(_socket); - if (!_socket) { - qCWarning(lcFileProviderSocketController) << "Cannot read data on dead socket"; - return; - } - - while(_socket->canReadLine()) { - const auto line = QString::fromUtf8(_socket->readLine().trimmed()).normalized(QString::NormalizationForm_C); - qCDebug(lcFileProviderSocketController) << "Received message in file provider socket:" << line; - - parseReceivedLine(line); - } -} - -void FileProviderSocketController::parseReceivedLine(const QString &receivedLine) -{ - if (receivedLine.isEmpty()) { - qCWarning(lcFileProviderSocketController) << "Received empty line, can't parse."; - return; - } - - const auto argPos = receivedLine.indexOf(QLatin1Char(':')); - if (argPos == -1) { - qCWarning(lcFileProviderSocketController) << "Received line:" - << receivedLine - << "is incorrectly structured. Can't parse."; - return; - } - - const auto command = receivedLine.mid(0, argPos); - const auto argument = receivedLine.mid(argPos + 1); - - if (command == QStringLiteral("FILE_PROVIDER_DOMAIN_IDENTIFIER_REQUEST_REPLY")) { - auto domainIdentifier = argument; - // Check if we have a port number who's colon has been replaced by a hyphen - // This is a workaround for the fact that we can't use colons as characters in domain names - // Let's check if, after the final hyphen, we have a number -- then it is a port number - const auto portColonPos = argument.lastIndexOf('-'); - const auto possiblePort = argument.mid(portColonPos + 1); - auto validInt = false; - const auto port = possiblePort.toInt(&validInt); - if (validInt && port > 0) { - domainIdentifier.replace(portColonPos, 1, ':'); - } - - _accountState = AccountManager::instance()->accountFromFileProviderDomainIdentifier(domainIdentifier); - sendIgnoreList(); - sendAccountDetails(); - return; - } - - qCWarning(lcFileProviderSocketController) << "Unknown command or reply:" << receivedLine; -} - -void FileProviderSocketController::sendMessage(const QString &message) const -{ - if (!_socket) { - const auto toLog = message.contains("ACCOUNT_DETAILS") ? "ACCOUNT_DETAILS:****" : message; - qCWarning(lcFileProviderSocketController) << "Not sending message on dead file provider socket:" << toLog; - return; - } - - if (message.contains("ACCOUNT_DETAILS:")) { - qCDebug(lcFileProviderSocketController) << "Sending File Provider socket message: ACCOUNT_DETAILS:****"; - } else { - qCDebug(lcFileProviderSocketController) << "Sending File Provider socket message:" << message; - } - const auto lineEndChar = '\n'; - const auto messageToSend = message.endsWith(lineEndChar) ? message : message + lineEndChar; - const auto bytesToSend = messageToSend.toUtf8(); - const auto sent = _socket->write(bytesToSend); - - if (sent != bytesToSend.length()) { - qCWarning(lcFileProviderSocketController) << "Could not send all data on file provider socket for:" << message; - } -} - -void FileProviderSocketController::start() -{ - Q_ASSERT(_socket); - if (!_socket) { - qCWarning(lcFileProviderSocketController) << "Cannot start communication on dead socket"; - return; - } - - /* - * We have a new file provider extension connection. When this happens, we: - * 1. Request the file provider domain identifier - * 2. Receive the file provider domain identifier from the extension - * 3. Send the account details to the extension according to the domain identifier - */ - requestFileProviderDomainInfo(); -} - -void FileProviderSocketController::requestFileProviderDomainInfo() const -{ - Q_ASSERT(_socket); - if (!_socket) { - qCWarning(lcFileProviderSocketController) << "Cannot request file provider domain data on dead socket"; - return; - } - - const auto requestMessage = QStringLiteral("SEND_FILE_PROVIDER_DOMAIN_IDENTIFIER"); - sendMessage(requestMessage); -} - -void FileProviderSocketController::slotAccountStateChanged(const AccountState::State state) const -{ - switch(state) { - case AccountState::Disconnected: - case AccountState::ConfigurationError: - case AccountState::NetworkError: - case AccountState::ServiceUnavailable: - case AccountState::MaintenanceMode: - // Do nothing, File Provider will by itself figure out connection issue - break; - case AccountState::SignedOut: - case AccountState::AskingCredentials: - case AccountState::RedirectDetected: - case AccountState::NeedToSignTermsOfService: - // Notify File Provider that it should show the not authenticated message - sendNotAuthenticated(); - break; - case AccountState::Connected: - // Provide credentials - sendAccountDetails(); - break; - } -} - -AccountStatePtr FileProviderSocketController::accountState() const -{ - return _accountState; -} - -void FileProviderSocketController::sendNotAuthenticated() const -{ - Q_ASSERT(_accountState); - const auto account = _accountState->account(); - Q_ASSERT(account); - - qCWarning(lcFileProviderSocketController) << "About to send not authenticated message to file provider extension" - << account->displayName(); - - const auto message = QString(QStringLiteral("ACCOUNT_NOT_AUTHENTICATED")); - sendMessage(message); -} - -void FileProviderSocketController::sendAccountDetails() const -{ - if (!_accountState) { - qCWarning(lcFileProviderSocketController) << "No account state available to send account details, stopping"; - return; - } - const auto account = _accountState->account(); - Q_ASSERT(account); - - qCInfo(lcFileProviderSocketController) << "About to send account details to file provider extension" - << account->displayName(); - - // Even though we have XPC send over the account details and related calls when the account state changes, in the - // brief window where we start the file provider extension on app startup and the account state changes, we need to - // be able to send over the details when the account is done getting configured. - connect(_accountState.data(), &AccountState::stateChanged, - this, &FileProviderSocketController::slotAccountStateChanged, Qt::UniqueConnection); - - if (!_accountState->isConnected()) { - qCWarning(lcFileProviderSocketController) << "Not sending account details yet as account is not connected" - << account->displayName(); - return; - } - - const auto credentials = account->credentials(); - Q_ASSERT(credentials); - const auto accountUser = credentials->user(); // User-provided username/email - const auto accountUserId = account->davUser(); // Backing user id on server - const auto accountUrl = account->url().toString(); // Server base URL - const auto accountPassword = credentials->password(); // Account password - - // We cannot use colons as separators here due to "https://" in the url - const auto message = QString(QStringLiteral("ACCOUNT_DETAILS:") + - Utility::userAgentString() + "~" + - accountUser + "~" + - accountUserId + "~" + - accountUrl + "~" + - accountPassword); - sendMessage(message); -} - -void FileProviderSocketController::sendIgnoreList() const -{ - if (!_accountState) { - qCWarning(lcFileProviderSocketController) << "No account state available to send ignore list, stopping"; - return; - } - - ExcludedFiles ignoreList; - ConfigFile::setupDefaultExcludeFilePaths(ignoreList); - ignoreList.reloadExcludeFiles(); - const auto patterns = ignoreList.activeExcludePatterns(); - if (patterns.isEmpty()) { - qCWarning(lcFileProviderSocketController) << "No active ignore list patterns, not sending."; - return; - } - // Try to come up with a separator that won't clash. I am hoping this is it - const auto message = QString(QStringLiteral("IGNORE_LIST:") + patterns.join("_~IL$~_")); - sendMessage(message); -} - -} // namespace Mac - -} // namespace OCC diff --git a/src/gui/macOS/fileprovidersocketcontroller.h b/src/gui/macOS/fileprovidersocketcontroller.h deleted file mode 100644 index c7283c9a75cf4..0000000000000 --- a/src/gui/macOS/fileprovidersocketcontroller.h +++ /dev/null @@ -1,54 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#pragma once - -#include - -#include "gui/accountstate.h" - -class QLocalSocket; - -namespace OCC { - -namespace Mac { - -class FileProviderSocketController : public QObject -{ - Q_OBJECT - -public: - explicit FileProviderSocketController(QLocalSocket * const socket, QObject * const parent = nullptr); - - [[nodiscard]] AccountStatePtr accountState() const; - -signals: - void socketDestroyed(const QLocalSocket * const socket); - -public slots: - void sendMessage(const QString &message) const; - void start(); - -private slots: - void slotOnDisconnected(); - void slotSocketDestroyed(const QObject * const object); - void slotReadyRead(); - - void slotAccountStateChanged(const OCC::AccountState::State state) const; - - void parseReceivedLine(const QString &receivedLine); - void requestFileProviderDomainInfo() const; - void sendAccountDetails() const; - void sendNotAuthenticated() const; - void sendIgnoreList() const; - -private: - QPointer _socket; - AccountStatePtr _accountState; -}; - -} // namespace Mac - -} // namespace OCC diff --git a/src/gui/macOS/fileprovidersocketserver.cpp b/src/gui/macOS/fileprovidersocketserver.cpp deleted file mode 100644 index 2b661f4250849..0000000000000 --- a/src/gui/macOS/fileprovidersocketserver.cpp +++ /dev/null @@ -1,84 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#include "fileprovidersocketserver.h" - -#include -#include - -#include "fileprovidersocketcontroller.h" - -namespace OCC { - -namespace Mac { - -Q_LOGGING_CATEGORY(lcFileProviderSocketServer, "nextcloud.gui.macos.fileprovider.socketserver", QtInfoMsg) - -FileProviderSocketServer::FileProviderSocketServer(QObject *parent) - : QObject{parent} -{ - qCDebug(lcFileProviderSocketServer) << "Initializing..."; - - _socketPath = fileProviderSocketPath(); - startListening(); - - qCDebug(lcFileProviderSocketServer) << "Initialized."; -} - -void FileProviderSocketServer::startListening() -{ - QLocalServer::removeServer(_socketPath); - - const auto serverStarted = _socketServer.listen(_socketPath); - - if (!serverStarted) { - qCWarning(lcFileProviderSocketServer) << "Could not start file provider socket server" - << _socketPath - << "Error:" - << _socketServer.errorString() - << "Error code:" - << _socketServer.serverError(); - } else { - qCInfo(lcFileProviderSocketServer) << "File provider socket server started, listening" - << _socketPath; - } - - connect(&_socketServer, &QLocalServer::newConnection, - this, &FileProviderSocketServer::slotNewConnection); -} - -void FileProviderSocketServer::slotNewConnection() -{ - if (!_socketServer.hasPendingConnections()) { - return; - } - - qCInfo(lcFileProviderSocketServer) << "New connection in file provider socket server"; - const auto socket = _socketServer.nextPendingConnection(); - if (!socket) { - return; - } - - const FileProviderSocketControllerPtr socketController(new FileProviderSocketController(socket, this)); - connect(socketController.data(), &FileProviderSocketController::socketDestroyed, - this, &FileProviderSocketServer::slotSocketDestroyed); - _socketControllers.insert(socket, socketController); - - socketController->start(); -} - -void FileProviderSocketServer::slotSocketDestroyed(const QLocalSocket * const socket) -{ - const auto socketController = _socketControllers.take(socket); - - if (socketController) { - const auto rawSocketControllerPtr = socketController.data(); - delete rawSocketControllerPtr; - } -} - -} // namespace Mac - -} // namespace OCC diff --git a/src/gui/macOS/fileprovidersocketserver.h b/src/gui/macOS/fileprovidersocketserver.h deleted file mode 100644 index d01bd941aba9d..0000000000000 --- a/src/gui/macOS/fileprovidersocketserver.h +++ /dev/null @@ -1,56 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#pragma once - -#include -#include -#include -#include -#include - -namespace OCC { - -namespace Mac { - -class FileProviderSocketController; -using FileProviderSocketControllerPtr = QPointer; - -QString fileProviderSocketPath(); - -/* - * Establishes communication between the app and the file provider extension. - * This is done via a local socket server. - * Note that this should be used for extension->client communication. - * - * We can communicate bidirectionally, but the File Provider XPC API is a better interface for this as we cannot account - * for the lifetime of a file provider extension when using sockets, and cannot control this on the client side. - * Use FileProviderXPC for client->extension communication when possible. - * - * This socket system is critical for the file provider extensions to be able to request authentication details. - * - * TODO: This should rewritten to use XPC instead of sockets - */ -class FileProviderSocketServer : public QObject -{ - Q_OBJECT - -public: - explicit FileProviderSocketServer(QObject *parent = nullptr); - -private slots: - void startListening(); - void slotNewConnection(); - void slotSocketDestroyed(const QLocalSocket * const socket); - -private: - QString _socketPath; - QLocalServer _socketServer; - QHash _socketControllers; -}; - -} // namespace Mac - -} // namespace OCC diff --git a/src/gui/macOS/fileprovidersocketserver_mac.mm b/src/gui/macOS/fileprovidersocketserver_mac.mm deleted file mode 100644 index c051cfead1c63..0000000000000 --- a/src/gui/macOS/fileprovidersocketserver_mac.mm +++ /dev/null @@ -1,28 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#import -#import - -#include "config.h" - -namespace OCC -{ - -namespace Mac -{ - -QString fileProviderSocketPath() -{ - NSString *appGroupId = [NSString stringWithFormat:@"%@.%@", @DEVELOPMENT_TEAM, @APPLICATION_REV_DOMAIN]; - NSURL *container = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:appGroupId]; - NSURL *socket = [container URLByAppendingPathComponent:@"fps" isDirectory:false]; - - return QString::fromNSString(socket.path); -} - -} // namespace Mac - -} // namespace OCC diff --git a/src/gui/macOS/findersyncservice.h b/src/gui/macOS/findersyncservice.h new file mode 100644 index 0000000000000..14ecd89cbb59d --- /dev/null +++ b/src/gui/macOS/findersyncservice.h @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include + +namespace OCC { + +class SocketApi; + +namespace Mac { + +/** + * @brief Service that implements the FinderSyncAppProtocol for XPC communication from FinderSync extensions. + * + * This class provides the implementation of the FinderSyncAppProtocol, allowing FinderSync extensions + * to communicate with the main application through XPC (Apple's Inter-Process Communication mechanism). + * + * **Architecture Note**: Despite its name, SocketApi is NOT socket-specific. It's the core business logic + * class that handles file status queries, menu commands, and folder operations across ALL platforms + * (Windows, Linux, macOS). This service acts as a transport adapter, translating XPC calls into + * SocketApi method calls, reusing the battle-tested business logic instead of duplicating code. + * + * **Transport Evolution**: + * - Legacy: FinderSync → UNIX Socket → SocketLineProcessor → SocketApi + * - Current: FinderSync → XPC → FinderSyncService → SocketApi + * + * The socket transport layer was removed, but the SocketApi business logic remains shared across platforms. + */ +class FinderSyncService : public QObject +{ + Q_OBJECT + +public: + explicit FinderSyncService(QObject *parent = nullptr); + ~FinderSyncService() override; + + /** + * @brief Get the Objective-C delegate object that implements FinderSyncAppProtocol. + * @return The delegate pointer (void* to avoid Objective-C in header). + */ + [[nodiscard]] void *delegate() const; + + /** + * @brief Set the SocketApi instance to forward requests to. + * + * Note: SocketApi is the core business logic class (not socket-specific) that handles + * file status queries, menu commands, and sync operations across all platforms. + * + * @param socketApi The SocketApi instance (must remain valid for the lifetime of this service). + */ + void setSocketApi(SocketApi *socketApi); + + /** + * @brief Get the current SocketApi instance. + * + * @return The SocketApi pointer, or nullptr if not set. + */ + [[nodiscard]] SocketApi *socketApi() const; + + /** + * @brief Get file data for a given path (accesses private SocketApi::FileData). + * + * This helper method exists because friend declarations don't extend to lambdas + * or Objective-C contexts. The friend relationship allows FinderSyncService to + * access FileData, and this method provides that access to our implementation. + * + * @param path The file path to query. + * @return A tuple of (hasFolder, statusString) where hasFolder indicates if the + * file is in a sync folder, and statusString is the status (e.g., "OK", "SYNC"). + */ + [[nodiscard]] std::pair getFileStatus(const QString &path) const; + + /** + * @brief Get localized strings from SocketApi. + * + * Calls SocketApi::command_GET_STRINGS and returns the result as a QMap. + * + * @return Map of string keys to localized values. + */ + [[nodiscard]] QMap getLocalizedStrings() const; + + /** + * @brief Get menu items for given paths from SocketApi. + * + * Calls SocketApi::command_GET_MENU_ITEMS and returns the parsed menu items. + * + * @param paths List of file paths to get menu items for. + * @return List of menu items (each item is a map with command, flags, text keys). + */ + [[nodiscard]] QList> getMenuItems(const QStringList &paths) const; + +private: + class MacImplementation; + std::unique_ptr d; + + SocketApi *_socketApi = nullptr; +}; + +} // namespace Mac + +} // namespace OCC diff --git a/src/gui/macOS/findersyncservice.mm b/src/gui/macOS/findersyncservice.mm new file mode 100644 index 0000000000000..3f7e84975cb3c --- /dev/null +++ b/src/gui/macOS/findersyncservice.mm @@ -0,0 +1,393 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "findersyncservice.h" + +#import +#import "FinderSyncAppProtocol.h" + +#include +#include +#include + +// Note: SocketApi is NOT socket-specific - it's the core business logic class that handles +// file status queries, menu commands, and sync operations across ALL platforms. +// We reuse SocketApi here as a transport adapter to avoid duplicating business logic. +#include "socketapi/socketapi.h" +#include "socketapi/socketapi_p.h" +#include "common/syncfilestatus.h" + +namespace OCC { + +Q_LOGGING_CATEGORY(lcMacFinderSyncService, "nextcloud.gui.macfindersyncservice", QtInfoMsg) + +} // namespace OCC + +/** + * @brief Objective-C delegate that implements the FinderSyncAppProtocol. + */ +@interface FinderSyncServiceDelegate : NSObject +@property (nonatomic, assign) OCC::Mac::FinderSyncService *service; +@end + +@implementation FinderSyncServiceDelegate + +- (void)retrieveFileStatusForPath:(NSString *)path + completionHandler:(void(^)(NSString *status, NSError *error))completionHandler +{ + const auto qPath = QString::fromNSString(path); + qCDebug(OCC::lcMacFinderSyncService) << "FinderSync requesting file status for:" << qPath; + + // Store socketApi pointer after null check to prevent TOCTOU race + auto *socketApi = _service ? _service->socketApi() : nullptr; + if (!socketApi) { + NSError *error = [NSError errorWithDomain:@"com.nextcloud.desktopclient.FinderSyncService" + code:1 + userInfo:@{NSLocalizedDescriptionKey: @"SocketApi not available"}]; + completionHandler(nil, error); + return; + } + + // Use QMetaObject::invokeMethod to call on the correct thread + QMetaObject::invokeMethod(socketApi, [service = _service, qPath, completionHandler]() { + // Get file status via FinderSyncService helper (which has friend access to FileData) + const auto [hasFolder, statusString] = service->getFileStatus(qPath); + + if (!hasFolder) { + qCWarning(OCC::lcMacFinderSyncService) << "File not in any sync folder:" << qPath; + NSError *error = [NSError errorWithDomain:@"com.nextcloud.desktopclient.FinderSyncService" + code:2 + userInfo:@{NSLocalizedDescriptionKey: @"File not in sync folder"}]; + completionHandler(nil, error); + return; + } + + qCDebug(OCC::lcMacFinderSyncService) << "Returning file status:" << statusString << "for:" << qPath; + completionHandler(statusString.toNSString(), nil); + }, Qt::QueuedConnection); +} + +- (void)retrieveFolderStatusForPath:(NSString *)path + completionHandler:(void(^)(NSString *status, NSError *error))completionHandler +{ + // Folders and files use the same status logic + [self retrieveFileStatusForPath:path completionHandler:completionHandler]; +} + +- (void)getLocalizedStringsWithCompletionHandler:(void(^)(NSDictionary *strings, NSError *error))completionHandler +{ + qCDebug(OCC::lcMacFinderSyncService) << "FinderSync requesting localized strings"; + + if (!_service) { + NSError *error = [NSError errorWithDomain:@"com.nextcloud.desktopclient.FinderSyncService" + code:1 + userInfo:@{NSLocalizedDescriptionKey: @"Service not available"}]; + completionHandler(nil, error); + return; + } + + // Get strings from SocketApi via FinderSyncService helper + const auto qStrings = _service->getLocalizedStrings(); + + // Convert QMap to NSDictionary + NSMutableDictionary *strings = [NSMutableDictionary dictionary]; + for (auto it = qStrings.constBegin(); it != qStrings.constEnd(); ++it) { + strings[it.key().toNSString()] = it.value().toNSString(); + } + + qCDebug(OCC::lcMacFinderSyncService) << "Returning" << strings.count << "localized strings"; + completionHandler([strings copy], nil); +} + +- (void)getMenuItemsForPaths:(NSArray *)paths + completionHandler:(void(^)(NSArray *menuItems, NSError *error))completionHandler +{ + if (!paths || paths.count == 0) { + NSError *error = [NSError errorWithDomain:@"com.nextcloud.desktopclient.FinderSyncService" + code:3 + userInfo:@{NSLocalizedDescriptionKey: @"No paths provided"}]; + completionHandler(nil, error); + return; + } + + qCDebug(OCC::lcMacFinderSyncService) << "FinderSync requesting menu items for" << paths.count << "paths"; + + if (!_service) { + NSError *error = [NSError errorWithDomain:@"com.nextcloud.desktopclient.FinderSyncService" + code:1 + userInfo:@{NSLocalizedDescriptionKey: @"Service not available"}]; + completionHandler(nil, error); + return; + } + + // Convert NSArray to QStringList + QStringList qPaths; + for (NSString *path in paths) { + qPaths << QString::fromNSString(path); + } + + // Get menu items from SocketApi via FinderSyncService helper + const auto qMenuItems = _service->getMenuItems(qPaths); + + // Convert QList to NSArray + NSMutableArray *menuItems = [NSMutableArray array]; + for (const auto &qItem : qMenuItems) { + NSMutableDictionary *item = [NSMutableDictionary dictionary]; + for (auto it = qItem.constBegin(); it != qItem.constEnd(); ++it) { + item[it.key().toNSString()] = it.value().toNSString(); + } + [menuItems addObject:[item copy]]; + } + + qCDebug(OCC::lcMacFinderSyncService) << "Returning" << menuItems.count << "menu items"; + completionHandler([menuItems copy], nil); +} + +- (void)executeMenuCommand:(NSString *)command + forPaths:(NSArray *)paths + completionHandler:(void(^)(NSError *error))completionHandler +{ + const auto qCommand = QString::fromNSString(command); + + QStringList qPaths; + for (NSString *path in paths) { + qPaths << QString::fromNSString(path); + } + + qCDebug(OCC::lcMacFinderSyncService) << "FinderSync executing command:" << qCommand + << "for" << qPaths.size() << "paths"; + + // Store socketApi pointer after null check to prevent TOCTOU race + auto *socketApi = _service ? _service->socketApi() : nullptr; + if (!socketApi) { + NSError *error = [NSError errorWithDomain:@"com.nextcloud.desktopclient.FinderSyncService" + code:1 + userInfo:@{NSLocalizedDescriptionKey: @"SocketApi not available"}]; + completionHandler(error); + return; + } + + // Use QMetaObject::invokeMethod to execute the command on SocketApi's thread + QMetaObject::invokeMethod(socketApi, [socketApi, qCommand, qPaths, completionHandler]() { + // Build the command method name (e.g., "SHARE" -> "command_SHARE") + const QString methodName = QStringLiteral("command_%1").arg(qCommand); + + // Create a null listener since we're not sending responses back via socket + // Commands will execute but won't send socket responses + OCC::SocketListener *nullListener = nullptr; + + // Use the first path as the argument (commands typically operate on the first selected file) + const QString argument = qPaths.isEmpty() ? QString() : qPaths.first(); + + // Try to invoke the command using Qt's meta-object system + bool invoked = QMetaObject::invokeMethod( + socketApi, + methodName.toUtf8().constData(), + Qt::DirectConnection, + Q_ARG(QString, argument), + Q_ARG(OCC::SocketListener*, nullListener) + ); + + if (invoked) { + qCDebug(OCC::lcMacFinderSyncService) << "Command executed successfully:" << qCommand; + completionHandler(nil); + } else { + qCWarning(OCC::lcMacFinderSyncService) << "Command execution failed (method not found):" << qCommand; + NSError *error = [NSError errorWithDomain:@"com.nextcloud.desktopclient.FinderSyncService" + code:4 + userInfo:@{NSLocalizedDescriptionKey: @"Command method not found"}]; + completionHandler(error); + } + }, Qt::QueuedConnection); +} + +@end + +namespace OCC { + +namespace Mac { + +class FinderSyncService::MacImplementation +{ +public: + FinderSyncServiceDelegate *delegate = nil; + + MacImplementation() + { + qCDebug(lcMacFinderSyncService) << "Initializing finder sync service"; + } + + ~MacImplementation() + { + [delegate release]; + delegate = nil; + } +}; + +FinderSyncService::FinderSyncService(QObject *parent) + : QObject(parent) + , d(std::make_unique()) +{ + qCDebug(lcMacFinderSyncService) << "FinderSyncService created"; + + d->delegate = [[FinderSyncServiceDelegate alloc] init]; + d->delegate.service = this; +} + +FinderSyncService::~FinderSyncService() +{ + qCDebug(lcMacFinderSyncService) << "FinderSyncService destroyed"; +} + +void *FinderSyncService::delegate() const +{ + return d ? d->delegate : nullptr; +} + +void FinderSyncService::setSocketApi(SocketApi *socketApi) +{ + _socketApi = socketApi; + qCDebug(lcMacFinderSyncService) << "SocketApi set for FinderSyncService"; +} + +SocketApi *FinderSyncService::socketApi() const +{ + return _socketApi; +} + +std::pair FinderSyncService::getFileStatus(const QString &path) const +{ + if (!_socketApi) { + return {false, QStringLiteral("NOP")}; + } + + // Access private SocketApi::FileData (allowed via friend declaration) + auto fileData = SocketApi::FileData::get(path); + + if (!fileData.folder) { + return {false, QStringLiteral("NOP")}; + } + + const auto status = fileData.syncFileStatus(); + + // Convert SyncFileStatus to string using the same logic as socket broadcasts + QString statusString; + if (status.tag() == SyncFileStatus::StatusSync) { + statusString = QStringLiteral("SYNC"); + } else if (status.tag() == SyncFileStatus::StatusUpToDate) { + statusString = status.shared() ? QStringLiteral("OK+SWM") : QStringLiteral("OK"); + } else if (status.tag() == SyncFileStatus::StatusWarning) { + statusString = QStringLiteral("IGNORE"); + } else if (status.tag() == SyncFileStatus::StatusError) { + statusString = QStringLiteral("ERROR"); + } else { + statusString = QStringLiteral("NOP"); + } + + return {true, statusString}; +} + +// Mock SocketListener for capturing command responses +namespace { +class MockSocketListener : public SocketListener +{ +public: + QStringList messages; + + explicit MockSocketListener() + : SocketListener(nullptr) + { + } + + void sendMessage(const QString &message, bool = false) + { + // Cast away const to store messages (this is a mock for testing) + messages.append(message); + } +}; +} // anonymous namespace + +QMap FinderSyncService::getLocalizedStrings() const +{ + QMap result; + + if (!_socketApi) { + return result; + } + + // Create mock listener to capture responses + MockSocketListener listener; + + // Call SocketApi command (synchronous) + _socketApi->command_GET_STRINGS(QString(), &listener); + + // Parse responses + // Format: STRING:KEY:VALUE between GET_STRINGS:BEGIN and GET_STRINGS:END + bool inStrings = false; + for (const QString &msg : listener.messages) { + if (msg == QStringLiteral("GET_STRINGS:BEGIN")) { + inStrings = true; + } else if (msg == QStringLiteral("GET_STRINGS:END")) { + break; + } else if (inStrings && msg.startsWith(QStringLiteral("STRING:"))) { + // Parse "STRING:KEY:VALUE" + const auto parts = msg.mid(7).split(':', Qt::SkipEmptyParts); // Skip "STRING:" + if (parts.size() >= 2) { + const QString key = parts[0]; + const QString value = parts.mid(1).join(':'); // Rejoin in case value contains ':' + result[key] = value; + } + } + } + + return result; +} + +QList> FinderSyncService::getMenuItems(const QStringList &paths) const +{ + QList> result; + + if (!_socketApi || paths.isEmpty()) { + return result; + } + + // Create mock listener to capture responses + MockSocketListener listener; + + // Join paths with record separator (same as socket protocol) + const QString argument = paths.join(QChar(0x1e)); + + // Call SocketApi command (synchronous) + _socketApi->command_GET_MENU_ITEMS(argument, &listener); + + // Parse responses + // Format: MENU_ITEM:command:flags:text between GET_MENU_ITEMS:BEGIN and GET_MENU_ITEMS:END + bool inMenuItems = false; + for (const QString &msg : listener.messages) { + if (msg == QStringLiteral("GET_MENU_ITEMS:BEGIN")) { + inMenuItems = true; + } else if (msg == QStringLiteral("GET_MENU_ITEMS:END")) { + break; + } else if (inMenuItems && msg.startsWith(QStringLiteral("MENU_ITEM:"))) { + // Parse "MENU_ITEM:command:flags:text" + const auto parts = msg.mid(10).split(':', Qt::KeepEmptyParts); // Skip "MENU_ITEM:" + if (parts.size() >= 3) { + QMap item; + item[QStringLiteral("command")] = parts[0]; + item[QStringLiteral("flags")] = parts[1]; + item[QStringLiteral("text")] = parts.mid(2).join(':'); // Rejoin in case text contains ':' + result.append(item); + } + } + } + + return result; +} + +} // namespace Mac + +} // namespace OCC + + diff --git a/src/gui/macOS/findersyncxpc.h b/src/gui/macOS/findersyncxpc.h new file mode 100644 index 0000000000000..bb7e503a3f738 --- /dev/null +++ b/src/gui/macOS/findersyncxpc.h @@ -0,0 +1,131 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include +#include + +namespace OCC::Mac { + +class FinderSyncService; + +/** + * @brief Establishes XPC communication between the app and the FinderSync extension. + * + * This class creates an NSXPCListener that the FinderSync extension can connect to, + * replacing the previous UNIX socket-based communication. The FinderSync extension + * exports FinderSyncProtocol, and the app exports FinderSyncAppProtocol. + */ +class FinderSyncXPC : public QObject +{ + Q_OBJECT + +public: + explicit FinderSyncXPC(QObject *parent = nullptr); + ~FinderSyncXPC() override; + + /** + * @brief Start the XPC listener for FinderSync connections. + * @param service The FinderSyncService that implements the app-side protocol. + */ + void startListener(Mac::FinderSyncService *service = nullptr); + + /** + * @brief Check if we have any active FinderSync extension connections. + * @return true if at least one extension is connected. + */ + [[nodiscard]] bool hasActiveConnections() const; + +public slots: + /** + * @brief Register a sync folder path with all connected FinderSync extensions. + * @param path The absolute path to register. + */ + void registerPath(const QString &path); + + /** + * @brief Unregister a sync folder path from all connected FinderSync extensions. + * @param path The absolute path to unregister. + */ + void unregisterPath(const QString &path); + + /** + * @brief Notify all extensions to update the view at the specified path. + * @param path The absolute path where the view should be refreshed. + */ + void updateViewAtPath(const QString &path); + + /** + * @brief Send a status update for a file/folder to all connected extensions. + * @param status The status string (e.g., "SYNC", "OK", "ERROR"). + * @param path The absolute path of the file/folder. + */ + void setStatusResult(const QString &status, const QString &path); + + /** + * @brief Send a localized string to all connected extensions. + * @param key The string key. + * @param value The localized string value. + */ + void setLocalizedString(const QString &key, const QString &value); + + /** + * @brief Reset menu items on all connected extensions. + */ + void resetMenuItems(); + + /** + * @brief Add a menu item to all connected extensions. + * @param command The command identifier. + * @param flags The menu item flags. + * @param text The menu item display text. + */ + void addMenuItem(const QString &command, const QString &flags, const QString &text); + + /** + * @brief Signal that menu items are complete. + */ + void menuItemsComplete(); + + /** + * @brief Store an extension proxy (called by listener delegate). + * @param connectionId Unique identifier for this connection. + * @param proxy The retained proxy object (void* to NSObject*). + * @param connection The NSXPCConnection object (void* to NSXPCConnection*). + */ + void storeExtensionProxy(const QString &connectionId, void *proxy, void *connection); + + /** + * @brief Remove an extension proxy when connection is invalidated (called by listener delegate). + * @param connection The NSXPCConnection object (void* to NSXPCConnection*). + */ + void removeExtensionProxy(void *connection); + +private: + //! Objective-C listener object (NSXPCListener*) + void *_listener = nullptr; + + //! Objective-C listener delegate (FinderSyncXPCListenerDelegate*) + //! Must be retained separately because NSXPCListener holds weak reference + void *_listenerDelegate = nullptr; + + //! Connected extension proxies, keyed by connection identifier + //! Values are NSObject* proxies + //! Protected by _proxiesMutex for thread-safe access + QHash _extensionProxies; + + //! Reverse mapping from NSXPCConnection* to connection identifier + //! Used to find connectionId when connection is invalidated + //! Protected by _proxiesMutex for thread-safe access + QHash _connectionToId; + + //! Mutex protecting _extensionProxies and _connectionToId access from multiple threads + //! (XPC listener thread, main thread, destructor) + mutable QMutex _proxiesMutex; +}; + +} // namespace OCC::Mac diff --git a/src/gui/macOS/findersyncxpc_mac.mm b/src/gui/macOS/findersyncxpc_mac.mm new file mode 100644 index 0000000000000..c526717f9750a --- /dev/null +++ b/src/gui/macOS/findersyncxpc_mac.mm @@ -0,0 +1,362 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "findersyncxpc.h" + +#import +#import "FinderSyncProtocol.h" +#import "FinderSyncAppProtocol.h" + +#include + +#include "findersyncservice.h" + +namespace OCC::Mac { + +Q_LOGGING_CATEGORY(lcFinderSyncXPC, "nextcloud.gui.macos.findersync.xpc", QtInfoMsg) + +} // namespace OCC::Mac + +/** + * @brief NSXPCListener delegate that accepts connections from FinderSync extensions. + */ +@interface FinderSyncXPCListenerDelegate : NSObject +@property (nonatomic, assign) OCC::Mac::FinderSyncXPC *finderSyncXPC; +@property (nonatomic, assign) OCC::Mac::FinderSyncService *service; +@property (nonatomic, assign) NSUInteger connectionCounter; +@end + +@implementation FinderSyncXPCListenerDelegate + +- (BOOL)listener:(NSXPCListener *)listener shouldAcceptNewConnection:(NSXPCConnection *)newConnection +{ + qCInfo(OCC::Mac::lcFinderSyncXPC) << "FinderSync extension attempting to connect via XPC"; + + if (!_service) { + qCWarning(OCC::Mac::lcFinderSyncXPC) << "No FinderSyncService available, rejecting connection"; + return NO; + } + + // Configure the connection + newConnection.exportedInterface = [NSXPCInterface interfaceWithProtocol:@protocol(FinderSyncAppProtocol)]; + newConnection.exportedObject = (__bridge id)_service->delegate(); + + newConnection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(FinderSyncProtocol)]; + + // Set up interruption and invalidation handlers + // Use __block for MRC - the connection won't be deallocated while blocks are alive + __block NSXPCConnection *blockConnection = newConnection; + newConnection.interruptionHandler = ^{ + qCWarning(OCC::Mac::lcFinderSyncXPC) << "FinderSync XPC connection interrupted"; + if (blockConnection) { + [blockConnection invalidate]; + } + }; + + newConnection.invalidationHandler = ^{ + qCInfo(OCC::Mac::lcFinderSyncXPC) << "FinderSync XPC connection invalidated"; + if (_finderSyncXPC && blockConnection) { + // Clean up the connection from our tracking + _finderSyncXPC->removeExtensionProxy((void *)CFBridgingRetain(blockConnection)); + } + }; + + // Resume the connection + [newConnection resume]; + + // Store the connection proxy + id proxy = [newConnection remoteObjectProxyWithErrorHandler:^(NSError *error) { + qCWarning(OCC::Mac::lcFinderSyncXPC) << "Error getting remote FinderSync proxy:" + << QString::fromNSString(error.localizedDescription); + }]; + + if (proxy && _finderSyncXPC) { + // Generate a unique connection ID + self.connectionCounter++; + QString connectionId = QStringLiteral("FinderSyncConnection_%1").arg(self.connectionCounter); + + qCInfo(OCC::Mac::lcFinderSyncXPC) << "Storing FinderSync extension proxy with ID:" << connectionId; + + // Store the proxy in the C++ object via public method (retain for manual memory management) + // CFBridgingRetain works both with and without ARC + // Also pass the connection for reverse mapping + _finderSyncXPC->storeExtensionProxy(connectionId, (void *)CFBridgingRetain(proxy), (void *)CFBridgingRetain(newConnection)); + } + + qCInfo(OCC::Mac::lcFinderSyncXPC) << "FinderSync XPC connection accepted and configured"; + return YES; +} + +@end + +namespace OCC::Mac { + +FinderSyncXPC::FinderSyncXPC(QObject *parent) + : QObject(parent) +{ + qCInfo(lcFinderSyncXPC) << "FinderSyncXPC initializing"; +} + +FinderSyncXPC::~FinderSyncXPC() +{ + qCInfo(lcFinderSyncXPC) << "FinderSyncXPC destroying"; + + // Clean up proxies and connections (release retained references) + // Lock mutex to safely access _extensionProxies and _connectionToId + { + QMutexLocker locker(&_proxiesMutex); + + // Release all proxies + for (auto it = _extensionProxies.begin(); it != _extensionProxies.end(); ++it) { + // Manual release for non-ARC code + // We stored with CFBridgingRetain (which does +1 retain), so we must release + id proxy = (__bridge id)it.value(); + [proxy release]; + } + _extensionProxies.clear(); + + // Release all connection objects from reverse mapping + for (auto it = _connectionToId.begin(); it != _connectionToId.end(); ++it) { + id connection = (__bridge id)it.key(); + [connection release]; + } + _connectionToId.clear(); + } + + // Clean up listener + if (_listener) { + NSXPCListener *listener = (__bridge NSXPCListener *)_listener; + [listener invalidate]; + [listener release]; + _listener = nullptr; + } + + // Clean up delegate (retained separately because listener holds weak reference) + if (_listenerDelegate) { + id delegate = (__bridge id)_listenerDelegate; + [delegate release]; + _listenerDelegate = nullptr; + } +} + +void FinderSyncXPC::startListener(Mac::FinderSyncService *service) +{ + qCInfo(lcFinderSyncXPC) << "Starting FinderSync XPC listener"; + + // Create a listener with a Mach service name + // The service name should match what the extension tries to connect to + NSString *serviceName = [[NSBundle mainBundle] bundleIdentifier]; + serviceName = [serviceName stringByAppendingString:@".FinderSyncService"]; + + qCInfo(lcFinderSyncXPC) << "Creating XPC listener with service name:" + << QString::fromNSString(serviceName); + + NSXPCListener *listener = [[NSXPCListener alloc] initWithMachServiceName:serviceName]; + + if (!listener) { + qCWarning(lcFinderSyncXPC) << "Failed to create XPC listener for FinderSync"; + return; + } + + FinderSyncXPCListenerDelegate *delegate = [[FinderSyncXPCListenerDelegate alloc] init]; + delegate.finderSyncXPC = this; + delegate.service = service; + delegate.connectionCounter = 0; + + listener.delegate = delegate; + [listener resume]; + + // Store listener with retained reference (works with and without ARC) + _listener = (void *)CFBridgingRetain(listener); + + // Store delegate with retained reference (NSXPCListener holds weak reference to delegate) + // Without this, delegate would be deallocated immediately after this method returns + _listenerDelegate = (void *)CFBridgingRetain(delegate); + + qCInfo(lcFinderSyncXPC) << "FinderSync XPC listener started successfully"; +} + +bool FinderSyncXPC::hasActiveConnections() const +{ + QMutexLocker locker(&_proxiesMutex); + return !_extensionProxies.isEmpty(); +} + +void FinderSyncXPC::storeExtensionProxy(const QString &connectionId, void *proxy, void *connection) +{ + QMutexLocker locker(&_proxiesMutex); + _extensionProxies.insert(connectionId, proxy); + _connectionToId.insert(connection, connectionId); + qCDebug(lcFinderSyncXPC) << "Stored extension proxy with ID:" << connectionId + << "Total connections:" << _extensionProxies.size(); +} + +void FinderSyncXPC::removeExtensionProxy(void *connection) +{ + QMutexLocker locker(&_proxiesMutex); + + // Look up the connection ID from the connection object + auto it = _connectionToId.find(connection); + if (it == _connectionToId.end()) { + qCWarning(lcFinderSyncXPC) << "Connection not found in reverse mapping, cannot remove proxy"; + // Release the connection we retained in invalidation handler + id conn = (__bridge id)connection; + [conn release]; + return; + } + + const QString connectionId = it.value(); + qCInfo(lcFinderSyncXPC) << "Removing extension proxy with ID:" << connectionId; + + // Remove and release the proxy + auto proxyIt = _extensionProxies.find(connectionId); + if (proxyIt != _extensionProxies.end()) { + id proxy = (__bridge id)proxyIt.value(); + [proxy release]; + _extensionProxies.erase(proxyIt); + } + + // Remove the connection from reverse mapping and release it + id conn = (__bridge id)connection; + [conn release]; + _connectionToId.erase(it); + + qCDebug(lcFinderSyncXPC) << "Removed extension proxy. Remaining connections:" << _extensionProxies.size(); +} + +void FinderSyncXPC::registerPath(const QString &path) +{ + QMutexLocker locker(&_proxiesMutex); + + if (_extensionProxies.isEmpty()) { + qCDebug(lcFinderSyncXPC) << "No FinderSync extensions connected, cannot register path:" << path; + return; + } + + NSString *nsPath = path.toNSString(); + qCDebug(lcFinderSyncXPC) << "Registering path with" << _extensionProxies.size() << "FinderSync extensions:" << path; + + for (auto it = _extensionProxies.begin(); it != _extensionProxies.end(); ++it) { + NSObject *proxy = (__bridge NSObject *)it.value(); + [proxy registerPath:nsPath]; + } +} + +void FinderSyncXPC::unregisterPath(const QString &path) +{ + QMutexLocker locker(&_proxiesMutex); + + if (_extensionProxies.isEmpty()) { + qCDebug(lcFinderSyncXPC) << "No FinderSync extensions connected, cannot unregister path:" << path; + return; + } + + NSString *nsPath = path.toNSString(); + qCDebug(lcFinderSyncXPC) << "Unregistering path with" << _extensionProxies.size() << "FinderSync extensions:" << path; + + for (auto it = _extensionProxies.begin(); it != _extensionProxies.end(); ++it) { + NSObject *proxy = (__bridge NSObject *)it.value(); + [proxy unregisterPath:nsPath]; + } +} + +void FinderSyncXPC::updateViewAtPath(const QString &path) +{ + QMutexLocker locker(&_proxiesMutex); + + if (_extensionProxies.isEmpty()) { + return; // Common case, don't log + } + + NSString *nsPath = path.toNSString(); + + for (auto it = _extensionProxies.begin(); it != _extensionProxies.end(); ++it) { + NSObject *proxy = (__bridge NSObject *)it.value(); + [proxy updateViewAtPath:nsPath]; + } +} + +void FinderSyncXPC::setStatusResult(const QString &status, const QString &path) +{ + QMutexLocker locker(&_proxiesMutex); + + if (_extensionProxies.isEmpty()) { + return; // Common case, don't log + } + + NSString *nsStatus = status.toNSString(); + NSString *nsPath = path.toNSString(); + + for (auto it = _extensionProxies.begin(); it != _extensionProxies.end(); ++it) { + NSObject *proxy = (__bridge NSObject *)it.value(); + [proxy setStatusResult:nsStatus forPath:nsPath]; + } +} + +void FinderSyncXPC::setLocalizedString(const QString &key, const QString &value) +{ + QMutexLocker locker(&_proxiesMutex); + + if (_extensionProxies.isEmpty()) { + qCDebug(lcFinderSyncXPC) << "No FinderSync extensions connected, cannot set localized string"; + return; + } + + NSString *nsKey = key.toNSString(); + NSString *nsValue = value.toNSString(); + + for (auto it = _extensionProxies.begin(); it != _extensionProxies.end(); ++it) { + NSObject *proxy = (__bridge NSObject *)it.value(); + [proxy setLocalizedString:nsValue forKey:nsKey]; + } +} + +void FinderSyncXPC::resetMenuItems() +{ + QMutexLocker locker(&_proxiesMutex); + + if (_extensionProxies.isEmpty()) { + return; + } + + for (auto it = _extensionProxies.begin(); it != _extensionProxies.end(); ++it) { + NSObject *proxy = (__bridge NSObject *)it.value(); + [proxy resetMenuItems]; + } +} + +void FinderSyncXPC::addMenuItem(const QString &command, const QString &flags, const QString &text) +{ + QMutexLocker locker(&_proxiesMutex); + + if (_extensionProxies.isEmpty()) { + return; + } + + NSString *nsCommand = command.toNSString(); + NSString *nsFlags = flags.toNSString(); + NSString *nsText = text.toNSString(); + + for (auto it = _extensionProxies.begin(); it != _extensionProxies.end(); ++it) { + NSObject *proxy = (__bridge NSObject *)it.value(); + [proxy addMenuItemWithCommand:nsCommand flags:nsFlags text:nsText]; + } +} + +void FinderSyncXPC::menuItemsComplete() +{ + QMutexLocker locker(&_proxiesMutex); + + if (_extensionProxies.isEmpty()) { + return; + } + + for (auto it = _extensionProxies.begin(); it != _extensionProxies.end(); ++it) { + NSObject *proxy = (__bridge NSObject *)it.value(); + [proxy menuItemsComplete]; + } +} + +} // namespace OCC::Mac diff --git a/src/gui/socketapi/socketapi.cpp b/src/gui/socketapi/socketapi.cpp index 58efdddaa7c69..2c655bf654baf 100644 --- a/src/gui/socketapi/socketapi.cpp +++ b/src/gui/socketapi/socketapi.cpp @@ -64,6 +64,10 @@ #ifdef Q_OS_MACOS #include #include "common/utility_mac_sandbox.h" +#ifdef BUILD_FILE_PROVIDER_MODULE +#include "macOS/findersyncxpc.h" +#include "application.h" +#endif #endif #ifdef HAVE_KGUIADDONS @@ -623,6 +627,16 @@ void SocketApi::broadcastStatusPushMessage(const QString &systemPath, SyncFileSt for (const auto &listener : std::as_const(_listeners)) { listener->sendMessageIfDirectoryMonitored(msg, directoryHash); } + +#if defined(Q_OS_MACOS) && defined(BUILD_FILE_PROVIDER_MODULE) + // Also broadcast to FinderSync via XPC + if (auto app = qobject_cast(qApp)) { + if (auto finderSyncXPC = app->finderSyncXPC()) { + const QString statusString = fileStatus.toSocketAPIString(); + finderSyncXPC->setStatusResult(statusString, systemPath); + } + } +#endif } void SocketApi::command_RETRIEVE_FOLDER_STATUS(const QString &argument, SocketListener *listener) diff --git a/src/gui/socketapi/socketapi.h b/src/gui/socketapi/socketapi.h index 5dcf6900e8c04..975c7d2fc0bf4 100644 --- a/src/gui/socketapi/socketapi.h +++ b/src/gui/socketapi/socketapi.h @@ -27,6 +27,10 @@ class SocketListener; class DirectEditor; class SocketApiJob; +namespace Mac { + class FinderSyncService; +} + Q_DECLARE_LOGGING_CATEGORY(lcSocketApi) #ifdef Q_OS_MACOS @@ -51,6 +55,9 @@ class SocketApi : public QObject NonRootEncryptedFolder }; + // Allow FinderSyncService to access FileData for XPC communication + friend class Mac::FinderSyncService; + public: explicit SocketApi(QObject *parent = nullptr); ~SocketApi() override;