From 601e53b11a757e2978d0e3729a0aa6169e1d2772 Mon Sep 17 00:00:00 2001 From: Iva Horn Date: Mon, 19 Jan 2026 15:05:12 +0100 Subject: [PATCH 1/3] fix(file-provider): Report synchronization status through XPC. - Introduced Objective-C AppProtocol for definition of requirements of the XPC service exposed by the main app. - The established XPC connections now are configured to export and recognize AppProtocol to enable bidirectional communication. - Introduced the FileProviderService class to implement AppProtocol on the app side in Objective-C++. - Outsourced ChangeNotificationInterface conformance of FileProviderExtension into dedicated source code file. Other: - Fixed get-task-allow entitlement for extensions in debug build configurations. Signed-off-by: Iva Horn --- shell_integration/MacOSX/CMakeLists.txt | 7 + .../MacOSX/FileProviderExt.entitlements.cmake | 1 + .../FileProviderUIExt.entitlements.cmake | 1 + .../MacOSX/FinderSyncExt.entitlements.cmake | 1 + .../Utilities/IgnoredFilesMatcher.swift | 6 +- .../IgnoredFilesMatcherTests.swift | 3 +- .../ItemCreateTests.swift | 2 +- .../ItemDeleteTests.swift | 2 +- .../ItemModifyTests.swift | 4 +- .../FileProviderExt-Bridging-Header.h | 1 + ...xtension+ChangeNotificationInterface.swift | 20 ++ ...xtension+ClientCommunicationProtocol.swift | 27 ++ ...ileProviderExtension+ClientInterface.swift | 248 ------------------ .../FileProviderExtension+CustomActions.swift | 6 +- ...xtension+NSFileProviderServiceSource.swift | 20 ++ ...derExtension+NSFileProviderServicing.swift | 30 +++ ...viderExtension+NSXPCListenerDelegate.swift | 39 +++ .../FileProviderExtension+Thumbnailing.swift | 6 +- .../FileProviderExtension.swift | 210 ++++++++++++++- .../FileProviderSocketLineProcessor.swift | 12 +- .../FileProviderExt/Services/AppProtocol.h | 25 ++ .../Services/ClientCommunicationService.swift | 70 ----- .../Services/FPUIExtensionService.swift | 5 - .../Services/FPUIExtensionServiceSource.swift | 5 - .../project.pbxproj | 30 ++- src/gui/CMakeLists.txt | 3 + src/gui/macOS/AppProtocol.h | 1 + src/gui/macOS/fileprovider.h | 3 + src/gui/macOS/fileprovider_mac.mm | 11 + src/gui/macOS/fileproviderservice.h | 73 ++++++ src/gui/macOS/fileproviderservice.mm | 144 ++++++++++ .../macOS/fileprovidersocketcontroller.cpp | 29 -- src/gui/macOS/fileprovidersocketcontroller.h | 4 - src/gui/macOS/fileprovidersocketserver.cpp | 19 -- src/gui/macOS/fileprovidersocketserver.h | 13 +- src/gui/macOS/fileproviderxpc_mac.mm | 9 +- src/gui/macOS/fileproviderxpc_mac_utils.h | 3 +- src/gui/macOS/fileproviderxpc_mac_utils.mm | 15 +- src/gui/owncloudgui.cpp | 5 +- src/gui/tray/syncstatussummary.cpp | 6 +- 40 files changed, 687 insertions(+), 432 deletions(-) create mode 100644 shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+ChangeNotificationInterface.swift create mode 100644 shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+ClientCommunicationProtocol.swift delete mode 100644 shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+ClientInterface.swift create mode 100644 shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+NSFileProviderServiceSource.swift create mode 100644 shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+NSFileProviderServicing.swift create mode 100644 shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+NSXPCListenerDelegate.swift create mode 100644 shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/Services/AppProtocol.h delete mode 100644 shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/Services/ClientCommunicationService.swift create mode 120000 src/gui/macOS/AppProtocol.h create mode 100644 src/gui/macOS/fileproviderservice.h create mode 100644 src/gui/macOS/fileproviderservice.mm diff --git a/shell_integration/MacOSX/CMakeLists.txt b/shell_integration/MacOSX/CMakeLists.txt index ad073826db40a..bf4c7962090e8 100644 --- a/shell_integration/MacOSX/CMakeLists.txt +++ b/shell_integration/MacOSX/CMakeLists.txt @@ -56,6 +56,13 @@ if(APPLE) endif() if (BUILD_OWNCLOUD_OSX_BUNDLE) + # Set debug entitlements conditionally based on build type + if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(DEBUG_ENTITLEMENTS "\tcom.apple.security.get-task-allow\n\t") + else() + set(DEBUG_ENTITLEMENTS "") + endif() + set(OSX_PLUGINS_BINARY_DIR ${CMAKE_CURRENT_BINARY_DIR}/${XCODE_TARGET_CONFIGURATION}) set(OSX_PLUGINS_INSTALL_DIR ${OWNCLOUD_OSX_BUNDLE}/Contents/PlugIns) diff --git a/shell_integration/MacOSX/FileProviderExt.entitlements.cmake b/shell_integration/MacOSX/FileProviderExt.entitlements.cmake index 4b2d676b9ee79..9962872dafa94 100644 --- a/shell_integration/MacOSX/FileProviderExt.entitlements.cmake +++ b/shell_integration/MacOSX/FileProviderExt.entitlements.cmake @@ -12,5 +12,6 @@ com.apple.security.network.server +@DEBUG_ENTITLEMENTS@ diff --git a/shell_integration/MacOSX/FileProviderUIExt.entitlements.cmake b/shell_integration/MacOSX/FileProviderUIExt.entitlements.cmake index 4b2d676b9ee79..9962872dafa94 100644 --- a/shell_integration/MacOSX/FileProviderUIExt.entitlements.cmake +++ b/shell_integration/MacOSX/FileProviderUIExt.entitlements.cmake @@ -12,5 +12,6 @@ com.apple.security.network.server +@DEBUG_ENTITLEMENTS@ diff --git a/shell_integration/MacOSX/FinderSyncExt.entitlements.cmake b/shell_integration/MacOSX/FinderSyncExt.entitlements.cmake index 27ccc7c06c746..f642a4daf08c0 100644 --- a/shell_integration/MacOSX/FinderSyncExt.entitlements.cmake +++ b/shell_integration/MacOSX/FinderSyncExt.entitlements.cmake @@ -8,5 +8,6 @@ @DEVELOPMENT_TEAM@.@APPLICATION_REV_DOMAIN@ +@DEBUG_ENTITLEMENTS@ diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/IgnoredFilesMatcher.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/IgnoredFilesMatcher.swift index 40611c320717d..117f9b0ec1789 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/IgnoredFilesMatcher.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/IgnoredFilesMatcher.swift @@ -4,6 +4,7 @@ import Foundation public class IgnoredFilesMatcher { + private let logger: FileProviderLogger private let regexes: [NSRegularExpression] private static func patternToRegex(_ pattern: String, wildcardsMatchSlash: Bool) -> String { @@ -42,7 +43,10 @@ public class IgnoredFilesMatcher { return hasSlash ? "^\(regex)$" : "(^|/)" + regex + "$" } - public init(ignoreList: [String], wildcardsMatchSlash: Bool = false) { + public init(ignoreList: [String], wildcardsMatchSlash: Bool = false, log: any FileProviderLogging) { + logger = FileProviderLogger(category: "IgnoredFilesMatcher", log: log) + logger.debug("Initializing with ignore list:\n\n\(ignoreList.map { "- \"\($0)\"" }.joined(separator: "\n"))") + regexes = ignoreList .map { Self.patternToRegex($0, wildcardsMatchSlash: wildcardsMatchSlash) } .compactMap { try? NSRegularExpression(pattern: $0, options: [.caseInsensitive]) } diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/IgnoredFilesMatcherTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/IgnoredFilesMatcherTests.swift index ea7b74121d44f..8514bc98fb32e 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/IgnoredFilesMatcherTests.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/IgnoredFilesMatcherTests.swift @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later @testable import NextcloudFileProviderKit +import NextcloudFileProviderKitMocks import Testing struct IgnoredFilesMatcherTests { @@ -14,7 +15,7 @@ struct IgnoredFilesMatcherTests { "deep/**" ] - let matcher = IgnoredFilesMatcher(ignoreList: patterns) + let matcher = IgnoredFilesMatcher(ignoreList: patterns, log: FileProviderLogMock()) #expect(matcher.isExcluded("foo.tmp")) #expect(matcher.isExcluded("a/b/c/hello.tmp")) diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift index 91c9494b01a4f..3e3707c0af1fc 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift @@ -524,7 +524,7 @@ final class ItemCreateTests: NextcloudFileProviderKitTestCase { } func testCreateDoesNotPropagateIgnoredFile() async throws { - let ignoredMatcher = IgnoredFilesMatcher(ignoreList: ["*.tmp", "/build/"]) + let ignoredMatcher = IgnoredFilesMatcher(ignoreList: ["*.tmp", "/build/"], log: FileProviderLogMock()) let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) // We'll create a file that matches the ignored pattern diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemDeleteTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemDeleteTests.swift index 236a1e4c4f988..00c39b123a9ef 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemDeleteTests.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemDeleteTests.swift @@ -162,7 +162,7 @@ final class ItemDeleteTests: NextcloudFileProviderKitTestCase { } func testDeleteDoesNotPropagateIgnoredFile() async throws { - let ignoredMatcher = IgnoredFilesMatcher(ignoreList: ["*.log", "/tmp/"]) + let ignoredMatcher = IgnoredFilesMatcher(ignoreList: ["*.log", "/tmp/"], log: FileProviderLogMock()) let metadata = SendableItemMetadata( ocId: "ignored-file-id", fileName: "debug.log", diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemModifyTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemModifyTests.swift index de71ef5b17abf..0b72e82ec91d3 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemModifyTests.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemModifyTests.swift @@ -1324,7 +1324,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { } func testModifyDoesNotPropagateIgnoredFile() async throws { - let ignoredMatcher = IgnoredFilesMatcher(ignoreList: ["*.bak", "/logs/"]) + let ignoredMatcher = IgnoredFilesMatcher(ignoreList: ["*.bak", "/logs/"], log: FileProviderLogMock()) let metadata = SendableItemMetadata( ocId: "ignored-modify-id", fileName: "error.bak", @@ -1355,7 +1355,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { func testModifyCreatesFileThatWasPreviouslyIgnoredWithContentsUrlProvided() async throws { let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) - let ignoredMatcher = IgnoredFilesMatcher(ignoreList: ["/logs/"]) + let ignoredMatcher = IgnoredFilesMatcher(ignoreList: ["/logs/"], log: FileProviderLogMock()) let tempFileName = UUID().uuidString let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent(tempFileName) diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExt-Bridging-Header.h b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExt-Bridging-Header.h index c1a3d2badebd2..008232c86be84 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExt-Bridging-Header.h +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExt-Bridging-Header.h @@ -6,6 +6,7 @@ #ifndef FileProviderExt_Bridging_Header_h #define FileProviderExt_Bridging_Header_h +#import "Services/AppProtocol.h" #import "Services/ClientCommunicationProtocol.h" #endif /* FileProviderExt_Bridging_Header_h */ diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+ChangeNotificationInterface.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+ChangeNotificationInterface.swift new file mode 100644 index 0000000000000..92d2ac9b1a025 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+ChangeNotificationInterface.swift @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-2.0-or-later + +import FileProvider +import NextcloudFileProviderKit + +extension FileProviderExtension: ChangeNotificationInterface { + func notifyChange() { + guard let fpManager = NSFileProviderManager(for: domain) else { + logger.error("Could not get file provider manager for domain \(self.domain.displayName), cannot notify changes") + return + } + + fpManager.signalEnumerator(for: .workingSet) { error in + if error != nil { + self.logger.error("Error signalling enumerator for working set, received error: \(error!.localizedDescription)") + } + } + } +} diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+ClientCommunicationProtocol.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+ClientCommunicationProtocol.swift new file mode 100644 index 0000000000000..9d4a81b25cfec --- /dev/null +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+ClientCommunicationProtocol.swift @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-2.0-or-later + +import NextcloudFileProviderKit + +extension FileProviderExtension: ClientCommunicationProtocol { + func getFileProviderDomainIdentifier(completionHandler: @escaping (String?, Error?) -> Void) { + logger.debug("Returning file provider domain identifier.", [.domain: domain.identifier.rawValue]) + completionHandler(domain.identifier.rawValue, nil) + } + + func configureAccount(withUser user: String, userId: String, serverUrl: String, password: String, userAgent: String) { + logger.info("Received account to configure.") + setupDomainAccount(user: user, userId: userId, serverUrl: serverUrl, password: password, userAgent: userAgent) + } + + func removeAccountConfig() { + logger.info("Received request to remove account data.") + dbManager = nil + ncAccount = nil + } + + func setIgnoreList(_ ignoreList: [String]) { + ignoredFiles = IgnoredFilesMatcher(ignoreList: ignoreList, log: log) + logger.info("Ignore list updated.") + } +} diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+ClientInterface.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+ClientInterface.swift deleted file mode 100644 index 405b6e7c7adaf..0000000000000 --- a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+ClientInterface.swift +++ /dev/null @@ -1,248 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -import FileProvider -import Foundation -import NCDesktopClientSocketKit -import NextcloudKit -import NextcloudFileProviderKit -import OSLog - -let AuthenticationTimeouts: [UInt64] = [ // Have progressively longer timeouts to not hammer server - 3_000_000_000, 6_000_000_000, 30_000_000_000, 60_000_000_000, 120_000_000_000, 300_000_000_000 -] - -extension FileProviderExtension: NSFileProviderServicing, ChangeNotificationInterface { - /* - This FileProviderExtension extension contains everything needed to communicate with the client. - We have two systems for communicating between the extensions and the client. - - Apple's XPC based File Provider APIs let us easily communicate client -> extension. - This is what ClientCommunicationService is for. - - We also use sockets, because the File Provider XPC system does not let us easily talk from - extension->client. - We need this because the extension needs to be able to request account details. We can't - reliably do this via XPC because the extensions get torn down by the system, out of the control - of the app, and we can receive nil/no services from NSFileProviderManager. Once this is done - then XPC works ok. - */ - func supportedServiceSources( - for itemIdentifier: NSFileProviderItemIdentifier, - completionHandler: @escaping ([NSFileProviderServiceSource]?, Error?) -> Void - ) -> Progress { - logger.debug("Serving supported service sources") - let clientCommService = ClientCommunicationService(fpExtension: self) - let fpuiExtService = FPUIExtensionServiceSource(fpExtension: self) - let services: [NSFileProviderServiceSource] = [clientCommService, fpuiExtService] - completionHandler(services, nil) - let progress = Progress() - progress.cancellationHandler = { - let error = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError) - completionHandler(nil, error) - } - return progress - } - - @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") - return - } - - assert(ncAccount != nil) - - fpManager.signalErrorResolved(NSFileProviderError(.notAuthenticated)) { error in - if error != nil { - self.logger.error("Error resolving not authenticated, received error: \(error!.localizedDescription)") - } - } - - logger.debug("Signalling enumerators for user \(self.ncAccount!.username) at server \(self.ncAccount!.serverUrl)") - - notifyChange() - } - - func notifyChange() { - guard let fpManager = NSFileProviderManager(for: domain) else { - logger.error("Could not get file provider manager for domain \(self.domain.displayName), cannot notify changes") - return - } - - fpManager.signalEnumerator(for: .workingSet) { error in - if error != nil { - self.logger.error("Error signalling enumerator for working set, received error: \(error!.localizedDescription)") - } - } - } - - /// - /// - Parameters: - /// - completionHandler: An optional completion handler which will be provided an error, if any occurred. Omitting this completion handler is fine, but you won't get notified of errors. - /// - @objc func setupDomainAccount( - user: String, - userId: String, - serverUrl: String, - password: String, - userAgent: String = "Nextcloud-macOS/FileProviderExt", - completionHandler: ((NSError?) -> Void)? = nil - ) { - let account = Account(user: user, id: userId, serverUrl: serverUrl, password: password) - - logger.info("Setting up domain account for user: \(user), userId: \(userId), serverUrl: \(serverUrl), password: \(password.isEmpty ? "" : ""), ncKitAccount: \(account.ncKitAccount)") - - guard account != ncAccount else { - logger.info("Cancelling domain account setup because of receiving the same account information repeatedly!") - completionHandler?(NSError(.invalidCredentials)) - return - } - - guard password.isEmpty == false else { - logger.info("Cancelling domain account setup because \"password\" is an empty string!") - completionHandler?(NSError(.missingAccountInformation)) - return - } - - guard serverUrl.isEmpty == false else { - logger.info("Cancelling domain account setup because \"serverUrl\" is an empty string!") - completionHandler?(NSError(.missingAccountInformation)) - return - } - - guard user.isEmpty == false else { - logger.info("Cancelling domain account setup because \"user\" is an empty string!") - completionHandler?(NSError(.missingAccountInformation)) - return - } - - guard userId.isEmpty == false else { - logger.info("Cancelling domain account setup because \"userId\" is an empty string!") - completionHandler?(NSError(.missingAccountInformation)) - return - } - - // Store account information independently from the main app for later access. - config.serverUrl = serverUrl - config.user = user - config.userId = userId - keychain.savePassword(password, for: user, on: serverUrl) - NextcloudKit.clearAccountErrorState(for: account.ncKitAccount) - - Task { - ncKit.appendSession( - account: account.ncKitAccount, - urlBase: serverUrl, - user: user, - userId: userId, - password: password, - userAgent: userAgent, - groupIdentifier: "" - ) - - var authAttemptState = AuthenticationAttemptResultState.connectionError // default - - // Retry a few times if we have a connection issue - let options = NKRequestOptions(checkInterceptor: false) - - for authTimeout in AuthenticationTimeouts { - authAttemptState = await ncKit.tryAuthenticationAttempt(account: account, options: options) - - guard authAttemptState == .connectionError else { - break - } - - logger.info("\(user) authentication try timed out. Trying again soon.") - try? await Task.sleep(nanoseconds: authTimeout) - } - - switch (authAttemptState) { - case .authenticationError: - logger.error("Authentication of \"\(user)\" failed due to bad credentials, cancelling domain account setup!") - completionHandler?(NSError(.invalidCredentials)) - return - case .connectionError: - // Despite multiple connection attempts we are still getting connection issues. - // Connection error should be provided - logger.error("Authentication of \"\(user)\" try failed, no connection.") - completionHandler?(NSError(.connection)) - return - case .success: - logger.info("Successfully authenticated! Nextcloud account set up in file provider extension. User: \(user) at server: \(serverUrl)") - } - - Task { @MainActor in - ncAccount = account - dbManager = FilesDatabaseManager(account: account, fileProviderDomainIdentifier: domain.identifier, log: log) - - if let changeObserver { - changeObserver.invalidate() - } - - if let dbManager { - changeObserver = RemoteChangeObserver( - account: account, - remoteInterface: ncKit, - changeNotificationInterface: self, - domain: domain, - dbManager: dbManager, - log: log - ) - } else { - logger.error("Invalid db manager, cannot start RCO") - } - - ncKit.setup(groupIdentifier: Bundle.main.bundleIdentifier!, delegate: changeObserver) - completionHandler?(nil) - signalEnumeratorAfterAccountSetup() - } - } - } - - @objc func removeAccountConfig() { - logger.info("Received instruction to remove account data for user \(self.ncAccount!.username) at server \(self.ncAccount!.serverUrl)") - ncAccount = nil - dbManager = nil - } - - func updatedSyncStateReporting(oldActions: Set) { - actionsLock.lock() - - guard oldActions.isEmpty != syncActions.isEmpty else { - logger.debug("Cancelling synchronization state report due to lack of state change.") - actionsLock.unlock() - return - } - - let command = "FILE_PROVIDER_DOMAIN_SYNC_STATE_CHANGE" - var argument: String? - - if oldActions.isEmpty, !syncActions.isEmpty { - argument = "SYNC_STARTED" - } else if !oldActions.isEmpty, syncActions.isEmpty { - argument = errorActions.isEmpty ? "SYNC_FINISHED" : "SYNC_FAILED" - errorActions = [] - } - - actionsLock.unlock() - - guard let argument else { - logger.error("State argument is nil!") - return - } - - logger.debug("Reporting synchronization state.", [.name: argument]) - - let message = command + ":" + argument + "\n" - socketClient?.sendMessage(message) - } -} diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+CustomActions.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+CustomActions.swift index dbc8c64b49c26..e5838d51e29c6 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+CustomActions.swift +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+CustomActions.swift @@ -1,7 +1,5 @@ -/* - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-2.0-or-later import FileProvider import NextcloudFileProviderKit diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+NSFileProviderServiceSource.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+NSFileProviderServiceSource.swift new file mode 100644 index 0000000000000..12cc315e1f595 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+NSFileProviderServiceSource.swift @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-2.0-or-later + +import FileProvider + +extension FileProviderExtension: NSFileProviderServiceSource { + func makeListenerEndpoint() throws -> NSXPCListenerEndpoint { + logger.info("Making listener endpoint...") + + // Invalidate existing listener. + listener.invalidate() + + // Set up a new listener. + listener = NSXPCListener.anonymous() + listener.delegate = self + listener.resume() + + return listener.endpoint + } +} diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+NSFileProviderServicing.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+NSFileProviderServicing.swift new file mode 100644 index 0000000000000..228d45ade67d9 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+NSFileProviderServicing.swift @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-2.0-or-later + +import FileProvider +import Foundation +import NCDesktopClientSocketKit +import NextcloudKit +import NextcloudFileProviderKit +import OSLog + +let AuthenticationTimeouts: [UInt64] = [ // Have progressively longer timeouts to not hammer the server + 3_000_000_000, 6_000_000_000, 30_000_000_000, 60_000_000_000, 120_000_000_000, 300_000_000_000 +] + +extension FileProviderExtension: NSFileProviderServicing { + func supportedServiceSources(for itemIdentifier: NSFileProviderItemIdentifier, completionHandler: @escaping ([NSFileProviderServiceSource]?, Error?) -> Void) -> Progress { + logger.debug("Serving supported service sources...") + let serviceSource = FPUIExtensionServiceSource(fpExtension: self) + completionHandler([self, serviceSource], nil) + let progress = Progress() + + progress.cancellationHandler = { + let error = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError) + completionHandler(nil, error) + self.logger.error("Cancellation handler of progress object for supported service sources call invoked!") + } + + return progress + } +} diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+NSXPCListenerDelegate.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+NSXPCListenerDelegate.swift new file mode 100644 index 0000000000000..29386f3236be8 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+NSXPCListenerDelegate.swift @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-2.0-or-later + +extension FileProviderExtension: NSXPCListenerDelegate { + func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { + logger.info("Listener should accept new connection...") + + newConnection.exportedInterface = NSXPCInterface(with: ClientCommunicationProtocol.self) + newConnection.exportedObject = self + newConnection.remoteObjectInterface = NSXPCInterface(with: AppProtocol.self) + + newConnection.interruptionHandler = { + self.logger.info("XPC connection interrupted!") + self.connections.remove(newConnection) + } + + newConnection.invalidationHandler = { + self.logger.info("XPC connection invalidated!") + self.connections.remove(newConnection) + } + + connections.insert(newConnection) + newConnection.resume() + + let remoteObjectProxy = newConnection.remoteObjectProxyWithErrorHandler { error in + self.logger.error("Error while fetching remote object proxy: \(error)") + } + + if let appService = remoteObjectProxy as? AppProtocol { + logger.info("Succeeded to cast remote object proxy, adopting it!") + self.app = appService + } else { + logger.error("Failed to cast remote object proxy to AppProtocol!") + self.app = nil + } + + return true + } +} diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+Thumbnailing.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+Thumbnailing.swift index c5c28b842dc76..39f586175b574 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+Thumbnailing.swift +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension+Thumbnailing.swift @@ -1,7 +1,5 @@ -/* - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ +// SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-2.0-or-later import FileProvider import Foundation diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension.swift index a8dd73d8b24e9..044ba1ba87619 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension.swift +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension.swift @@ -1,7 +1,5 @@ -/* - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ +// SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-2.0-or-later import FileProvider import NCDesktopClientSocketKit @@ -9,13 +7,35 @@ import NextcloudKit import NextcloudFileProviderKit import OSLog -@objc class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { +/// +/// The file provider replicated extension implementation. +/// +@objc final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension, @unchecked Sendable { let domain: NSFileProviderDomain let keychain: Keychain let log: any FileProviderLogging let logger: FileProviderLogger + // MARK: XPC + + /// + /// The remote object proxy to interact with the app. + /// + /// This is updated by the `NSXPCListenerDelegate` implementation. + /// + var app: (any AppProtocol)? + + /// + /// Connections established by the `NSXPCListenerDelegate` extension on this type. + /// + /// The individual interr + /// + var connections = Set() + + var listener = NSXPCListener.anonymous() + let serviceName = NSFileProviderServiceName("com.nextcloud.desktopclient.ClientCommunicationService") + /// /// NextcloudKit instance used by this file provider extension object. /// @@ -528,4 +548,184 @@ 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") + return + } + + assert(ncAccount != nil) + + fpManager.signalErrorResolved(NSFileProviderError(.notAuthenticated)) { error in + if error != nil { + self.logger.error("Error resolving not authenticated, received error: \(error!.localizedDescription)") + } + } + + logger.debug("Signalling enumerators for user \(self.ncAccount!.username) at server \(self.ncAccount!.serverUrl)") + + notifyChange() + } + + /// + /// - Parameters: + /// - completionHandler: An optional completion handler which will be provided an error, if any occurred. Omitting this completion handler is fine, but you won't get notified of errors. + /// + @objc func setupDomainAccount( + user: String, + userId: String, + serverUrl: String, + password: String, + userAgent: String = "Nextcloud-macOS/FileProviderExt", + completionHandler: ((NSError?) -> Void)? = nil + ) { + let account = Account(user: user, id: userId, serverUrl: serverUrl, password: password) + + logger.info("Setting up domain account for user: \(user), userId: \(userId), serverUrl: \(serverUrl), password: \(password.isEmpty ? "" : ""), ncKitAccount: \(account.ncKitAccount)") + + guard account != ncAccount else { + logger.info("Cancelling domain account setup because of receiving the same account information repeatedly!") + completionHandler?(NSError(.invalidCredentials)) + return + } + + guard password.isEmpty == false else { + logger.info("Cancelling domain account setup because \"password\" is an empty string!") + completionHandler?(NSError(.missingAccountInformation)) + return + } + + guard serverUrl.isEmpty == false else { + logger.info("Cancelling domain account setup because \"serverUrl\" is an empty string!") + completionHandler?(NSError(.missingAccountInformation)) + return + } + + guard user.isEmpty == false else { + logger.info("Cancelling domain account setup because \"user\" is an empty string!") + completionHandler?(NSError(.missingAccountInformation)) + return + } + + guard userId.isEmpty == false else { + logger.info("Cancelling domain account setup because \"userId\" is an empty string!") + completionHandler?(NSError(.missingAccountInformation)) + return + } + + // Store account information independently from the main app for later access. + config.serverUrl = serverUrl + config.user = user + config.userId = userId + keychain.savePassword(password, for: user, on: serverUrl) + NextcloudKit.clearAccountErrorState(for: account.ncKitAccount) + + Task { + ncKit.appendSession( + account: account.ncKitAccount, + urlBase: serverUrl, + user: user, + userId: userId, + password: password, + userAgent: userAgent, + groupIdentifier: "" + ) + + var authAttemptState = AuthenticationAttemptResultState.connectionError // default + + // Retry a few times if we have a connection issue + let options = NKRequestOptions(checkInterceptor: false) + + for authTimeout in AuthenticationTimeouts { + authAttemptState = await ncKit.tryAuthenticationAttempt(account: account, options: options) + + guard authAttemptState == .connectionError else { + break + } + + logger.info("\(user) authentication try timed out. Trying again soon.") + try? await Task.sleep(nanoseconds: authTimeout) + } + + switch (authAttemptState) { + case .authenticationError: + logger.error("Authentication of \"\(user)\" failed due to bad credentials, cancelling domain account setup!") + completionHandler?(NSError(.invalidCredentials)) + return + case .connectionError: + // Despite multiple connection attempts we are still getting connection issues. + // Connection error should be provided + logger.error("Authentication of \"\(user)\" try failed, no connection.") + completionHandler?(NSError(.connection)) + return + case .success: + logger.info("Successfully authenticated! Nextcloud account set up in file provider extension. User: \(user) at server: \(serverUrl)") + } + + Task { @MainActor in + ncAccount = account + dbManager = FilesDatabaseManager(account: account, fileProviderDomainIdentifier: domain.identifier, log: log) + + if let changeObserver { + changeObserver.invalidate() + } + + if let dbManager { + changeObserver = RemoteChangeObserver( + account: account, + remoteInterface: ncKit, + changeNotificationInterface: self, + domain: domain, + dbManager: dbManager, + log: log + ) + } else { + logger.error("Invalid db manager, cannot start RCO") + } + + ncKit.setup(groupIdentifier: Bundle.main.bundleIdentifier!, delegate: changeObserver) + completionHandler?(nil) + signalEnumeratorAfterAccountSetup() + } + } + } + + func updatedSyncStateReporting(oldActions: Set) { + actionsLock.lock() + + guard oldActions.isEmpty != syncActions.isEmpty else { + logger.debug("Cancelling synchronization state report due to lack of state change.") + actionsLock.unlock() + return + } + + var argument: String? + + if oldActions.isEmpty, !syncActions.isEmpty { + argument = "SYNC_STARTED" + } else if !oldActions.isEmpty, syncActions.isEmpty { + argument = errorActions.isEmpty ? "SYNC_FINISHED" : "SYNC_FAILED" + errorActions = [] + } + + actionsLock.unlock() + + guard let argument else { + logger.error("State argument is nil!") + return + } + + logger.debug("Reporting synchronization state.", [.name: argument]) + + let message = command + ":" + argument + "\n" + app?.reportSyncStatus(message) + } } diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderSocketLineProcessor.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderSocketLineProcessor.swift index bca47bb037af3..c44a54dd65ebd 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderSocketLineProcessor.swift +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderSocketLineProcessor.swift @@ -1,7 +1,5 @@ -/* - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ +// SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-2.0-or-later import Foundation import NCDesktopClientSocketKit @@ -10,10 +8,12 @@ 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) } @@ -62,8 +62,8 @@ class FileProviderSocketLineProcessor: NSObject, LineProcessor { return } let ignoreList = ignoreListSubsequence.components(separatedBy: "_~IL$~_") - logger.debug("Applying \(ignoreList.count) ignore file patterns") - delegate.ignoredFiles = IgnoredFilesMatcher(ignoreList: ignoreList) + logger.debug("Applying \(ignoreList.count) ignore file patterns.") + delegate.ignoredFiles = IgnoredFilesMatcher(ignoreList: ignoreList, log: log) } } } diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/Services/AppProtocol.h b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/Services/AppProtocol.h new file mode 100644 index 0000000000000..08569ab826f6d --- /dev/null +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/Services/AppProtocol.h @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef AppProtocol_h +#define AppProtocol_h + +#import + +/** + * @brief The main app APIs exposed through XPC. + */ +@protocol AppProtocol + +/** + * @brief The file provider extension can report its synchronization status as a string constant value to the main app through this method. + * @param status The synchronization status string. + * @param domainIdentifier The file provider domain identifier for which the status is reported. + */ +- (void)reportSyncStatus:(NSString *)status forDomainIdentifier:(NSString *)domainIdentifier; + +@end + +#endif /* AppProtocol_h */ diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/Services/ClientCommunicationService.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/Services/ClientCommunicationService.swift deleted file mode 100644 index b9fb2ed273a2c..0000000000000 --- a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/Services/ClientCommunicationService.swift +++ /dev/null @@ -1,70 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -import Foundation -import FileProvider -import NextcloudFileProviderKit -import OSLog - -class ClientCommunicationService: NSObject, NSFileProviderServiceSource, NSXPCListenerDelegate, ClientCommunicationProtocol { - let listener = NSXPCListener.anonymous() - let logger: FileProviderLogger - let serviceName = NSFileProviderServiceName("com.nextcloud.desktopclient.ClientCommunicationService") - let fpExtension: FileProviderExtension - - init(fpExtension: FileProviderExtension) { - self.logger = FileProviderLogger(category: "ClientCommunicationService", log: fpExtension.log) - logger.debug("Instantiating client communication service") - self.fpExtension = fpExtension - super.init() - } - - func makeListenerEndpoint() throws -> NSXPCListenerEndpoint { - listener.delegate = self - listener.resume() - return listener.endpoint - } - - func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { - newConnection.exportedInterface = NSXPCInterface(with: ClientCommunicationProtocol.self) - newConnection.exportedObject = self - newConnection.resume() - return true - } - - //MARK: - Client Communication Protocol methods - - func getFileProviderDomainIdentifier(completionHandler: @escaping (String?, Error?) -> Void) { - let identifier = self.fpExtension.domain.identifier.rawValue - logger.debug("Returning file provider domain identifier.", [.domain: identifier]) - completionHandler(identifier, nil) - } - - func configureAccount( - withUser user: String, - userId: String, - serverUrl: String, - password: String, - userAgent: String - ) { - logger.info("Received configure account information over client communication service") - self.fpExtension.setupDomainAccount( - user: user, - userId: userId, - serverUrl: serverUrl, - password: password, - userAgent: userAgent - ) - } - - func removeAccountConfig() { - self.fpExtension.removeAccountConfig() - } - - func setIgnoreList(_ ignoreList: [String]) { - self.fpExtension.ignoredFiles = IgnoredFilesMatcher(ignoreList: ignoreList) - logger.info("Ignore list updated.") - } -} diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/Services/FPUIExtensionService.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/Services/FPUIExtensionService.swift index da2f95e171d74..940d5c947755c 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/Services/FPUIExtensionService.swift +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/Services/FPUIExtensionService.swift @@ -1,10 +1,5 @@ -// -// FPUIExtensionCommunicationProtocol.swift -// FileProviderExt -// // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors // SPDX-License-Identifier: GPL-2.0-or-later -// import FileProvider import NextcloudKit diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/Services/FPUIExtensionServiceSource.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/Services/FPUIExtensionServiceSource.swift index 8ef39443f0af1..03b93174cbb67 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/Services/FPUIExtensionServiceSource.swift +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/Services/FPUIExtensionServiceSource.swift @@ -1,10 +1,5 @@ -// -// FPUIExtensionCommunicationService.swift -// FileProviderExt -// // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors // SPDX-License-Identifier: GPL-2.0-or-later -// import FileProvider import Foundation diff --git a/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj b/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj index b5af734e0482b..deb1e17c48f5b 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj +++ b/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ 530429982DD44235004BB598 /* FileProviderExtension+CustomActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 530429972DD44226004BB598 /* FileProviderExtension+CustomActions.swift */; }; 531522822B8E01C6002E31BE /* ShareTableItemView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 531522812B8E01C6002E31BE /* ShareTableItemView.xib */; }; - 5350E4E92B0C534A00F276CB /* ClientCommunicationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5350E4E82B0C534A00F276CB /* ClientCommunicationService.swift */; }; 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 */; }; @@ -39,7 +38,7 @@ 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+ClientInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53ED472F29C9CE0B00795DB1 /* FileProviderExtension+ClientInterface.swift */; }; + 53ED473029C9CE0B00795DB1 /* FileProviderExtension+NSFileProviderServicing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53ED472F29C9CE0B00795DB1 /* FileProviderExtension+NSFileProviderServicing.swift */; }; 53FE14502B8E0658006C4193 /* ShareTableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FE144F2B8E0658006C4193 /* ShareTableViewDataSource.swift */; }; 53FE14592B8E3F6C006C4193 /* ShareTableItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FE14582B8E3F6C006C4193 /* ShareTableItemView.swift */; }; 53FE145B2B8F1305006C4193 /* NKShare+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FE145A2B8F1305006C4193 /* NKShare+Extensions.swift */; }; @@ -54,6 +53,10 @@ AA7F17E72E7038370000E928 /* NSError+FileProviderErrorCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA7F17E62E7038340000E928 /* NSError+FileProviderErrorCode.swift */; }; AA9987862E72B6EF00B2C428 /* NextcloudKit+clearAccountErrorState.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9987852E72B6DB00B2C428 /* NextcloudKit+clearAccountErrorState.swift */; }; AAA69D932E3BB09900BBD44D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AAA69D922E3BB09900BBD44D /* Localizable.xcstrings */; }; + AAA6F1792F1E608000FFB2BA /* FileProviderExtension+ChangeNotificationInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA6F1782F1E607B00FFB2BA /* FileProviderExtension+ChangeNotificationInterface.swift */; }; + 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 */; }; 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 */; }; @@ -165,7 +168,6 @@ 530429972DD44226004BB598 /* FileProviderExtension+CustomActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileProviderExtension+CustomActions.swift"; sourceTree = ""; }; 531522812B8E01C6002E31BE /* ShareTableItemView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ShareTableItemView.xib; sourceTree = ""; }; 5350E4E72B0C514400F276CB /* ClientCommunicationProtocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ClientCommunicationProtocol.h; sourceTree = ""; }; - 5350E4E82B0C534A00F276CB /* ClientCommunicationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCommunicationService.swift; sourceTree = ""; }; 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 = ""; }; @@ -194,7 +196,7 @@ 53B979802B84C81F002DA742 /* DocumentActionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentActionViewController.swift; sourceTree = ""; }; 53B979852B84C81F002DA742 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 53D666602B70C9A70042C03D /* FileProviderDomainDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderDomainDefaults.swift; sourceTree = ""; }; - 53ED472F29C9CE0B00795DB1 /* FileProviderExtension+ClientInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileProviderExtension+ClientInterface.swift"; sourceTree = ""; }; + 53ED472F29C9CE0B00795DB1 /* FileProviderExtension+NSFileProviderServicing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileProviderExtension+NSFileProviderServicing.swift"; sourceTree = ""; }; 53FE144F2B8E0658006C4193 /* ShareTableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareTableViewDataSource.swift; sourceTree = ""; }; 53FE14582B8E3F6C006C4193 /* ShareTableItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareTableItemView.swift; sourceTree = ""; }; 53FE145A2B8F1305006C4193 /* NKShare+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NKShare+Extensions.swift"; sourceTree = ""; }; @@ -211,6 +213,11 @@ AA826BEE2E8EAA7500CE49C4 /* Build.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Build.xcconfig; sourceTree = ""; }; AA9987852E72B6DB00B2C428 /* NextcloudKit+clearAccountErrorState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NextcloudKit+clearAccountErrorState.swift"; sourceTree = ""; }; AAA69D922E3BB09900BBD44D /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + AAA6F1782F1E607B00FFB2BA /* FileProviderExtension+ChangeNotificationInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileProviderExtension+ChangeNotificationInterface.swift"; sourceTree = ""; }; + AAA6F17C2F1E647800FFB2BA /* FileProviderExtension+NSFileProviderServiceSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileProviderExtension+NSFileProviderServiceSource.swift"; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; @@ -277,8 +284,8 @@ 5350E4C72B0C368B00F276CB /* Services */ = { isa = PBXGroup; children = ( + AAAF76222F1A83A900BD0F0D /* AppProtocol.h */, 5350E4E72B0C514400F276CB /* ClientCommunicationProtocol.h */, - 5350E4E82B0C534A00F276CB /* ClientCommunicationService.swift */, 537630962B860D920026BFAB /* FPUIExtensionService.swift */, 537630942B860D560026BFAB /* FPUIExtensionServiceSource.swift */, ); @@ -342,7 +349,11 @@ 5350E4C72B0C368B00F276CB /* Services */, 53D666602B70C9A70042C03D /* FileProviderDomainDefaults.swift */, 538E396C27F4765000FA63D5 /* FileProviderExtension.swift */, - 53ED472F29C9CE0B00795DB1 /* FileProviderExtension+ClientInterface.swift */, + AAA6F17C2F1E647800FFB2BA /* FileProviderExtension+NSFileProviderServiceSource.swift */, + AAA6F1802F1E652E00FFB2BA /* FileProviderExtension+ClientCommunicationProtocol.swift */, + AAA6F1782F1E607B00FFB2BA /* FileProviderExtension+ChangeNotificationInterface.swift */, + AAA6F17E2F1E64F100FFB2BA /* FileProviderExtension+NSXPCListenerDelegate.swift */, + 53ED472F29C9CE0B00795DB1 /* FileProviderExtension+NSFileProviderServicing.swift */, 530429972DD44226004BB598 /* FileProviderExtension+CustomActions.swift */, 5352B36B29DC44B50011CE03 /* FileProviderExtension+Thumbnailing.swift */, 536EFBF6295CF58100F4CB13 /* FileProviderSocketLineProcessor.swift */, @@ -857,7 +868,9 @@ files = ( AA9987862E72B6EF00B2C428 /* NextcloudKit+clearAccountErrorState.swift in Sources */, 53D666612B70C9A70042C03D /* FileProviderDomainDefaults.swift in Sources */, - 53ED473029C9CE0B00795DB1 /* FileProviderExtension+ClientInterface.swift in Sources */, + AAA6F17D2F1E647D00FFB2BA /* FileProviderExtension+NSFileProviderServiceSource.swift in Sources */, + 53ED473029C9CE0B00795DB1 /* FileProviderExtension+NSFileProviderServicing.swift in Sources */, + AAA6F1792F1E608000FFB2BA /* FileProviderExtension+ChangeNotificationInterface.swift in Sources */, 538E396D27F4765000FA63D5 /* FileProviderExtension.swift in Sources */, AA7F17E72E7038370000E928 /* NSError+FileProviderErrorCode.swift in Sources */, 536EFBF7295CF58100F4CB13 /* FileProviderSocketLineProcessor.swift in Sources */, @@ -865,7 +878,8 @@ 537630972B860D920026BFAB /* FPUIExtensionService.swift in Sources */, 537630952B860D560026BFAB /* FPUIExtensionServiceSource.swift in Sources */, 530429982DD44235004BB598 /* FileProviderExtension+CustomActions.swift in Sources */, - 5350E4E92B0C534A00F276CB /* ClientCommunicationService.swift in Sources */, + AAA6F1812F1E653300FFB2BA /* FileProviderExtension+ClientCommunicationProtocol.swift in Sources */, + AAA6F17F2F1E64F500FFB2BA /* FileProviderExtension+NSXPCListenerDelegate.swift in Sources */, 5352B36C29DC44B50011CE03 /* FileProviderExtension+Thumbnailing.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 66a73d7e912e4..0b9097cb152a7 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -296,6 +296,7 @@ IF( APPLE ) if (BUILD_FILE_PROVIDER_MODULE) list(APPEND client_SRCS # Symlinks to files in shell_integration/MacOSX/NextcloudIntegration/ + macOS/AppProtocol.h macOS/ClientCommunicationProtocol.h # End of symlink files macOS/fileprovider.h @@ -308,6 +309,8 @@ IF( APPLE ) macOS/fileprovideritemmetadata.h macOS/fileprovideritemmetadata.cpp macOS/fileprovideritemmetadata_mac.mm + macOS/fileproviderservice.h + macOS/fileproviderservice.mm macOS/fileprovidersettingscontroller.h macOS/fileprovidersettingscontroller_mac.mm macOS/fileprovidersocketcontroller.h diff --git a/src/gui/macOS/AppProtocol.h b/src/gui/macOS/AppProtocol.h new file mode 120000 index 0000000000000..e8b5897d932a9 --- /dev/null +++ b/src/gui/macOS/AppProtocol.h @@ -0,0 +1 @@ +../../../shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/Services/AppProtocol.h \ No newline at end of file diff --git a/src/gui/macOS/fileprovider.h b/src/gui/macOS/fileprovider.h index 5162e5644a922..d1dd9bc5f346d 100644 --- a/src/gui/macOS/fileprovider.h +++ b/src/gui/macOS/fileprovider.h @@ -8,6 +8,7 @@ #include #include "fileproviderdomainmanager.h" +#include "fileproviderservice.h" #include "fileprovidersocketserver.h" #include "fileproviderxpc.h" @@ -32,11 +33,13 @@ class FileProvider : public QObject [[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; static FileProvider *_instance; explicit FileProvider(QObject * const parent = nullptr); diff --git a/src/gui/macOS/fileprovider_mac.mm b/src/gui/macOS/fileprovider_mac.mm index b29f9335cbc72..33a99d68b3707 100644 --- a/src/gui/macOS/fileprovider_mac.mm +++ b/src/gui/macOS/fileprovider_mac.mm @@ -37,6 +37,12 @@ if (_socketServer) { qCDebug(lcMacFileProvider) << "Initialised file provider socket server."; } + + _service = std::make_unique(this); + + if (_service) { + qCDebug(lcMacFileProvider) << "Initialised file provider service."; + } } FileProvider *FileProvider::instance() @@ -81,5 +87,10 @@ return _socketServer.get(); } +FileProviderService *FileProvider::service() const +{ + return _service.get(); +} + } // namespace Mac } // namespace OCC diff --git a/src/gui/macOS/fileproviderservice.h b/src/gui/macOS/fileproviderservice.h new file mode 100644 index 0000000000000..98fe8ba9334ff --- /dev/null +++ b/src/gui/macOS/fileproviderservice.h @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include +#include + +#include "libsync/account.h" +#include "libsync/syncresult.h" + +namespace OCC { + +namespace Mac { + +/** + * @brief Service that implements the AppProtocol for XPC communication from file provider extensions. + * + * This class provides the implementation of the AppProtocol, allowing file provider extensions + * to communicate with the main application through XPC. The actual XPC connection management + * is handled by FileProviderXPCUtils::processClientCommunicationConnections. + */ +class FileProviderService : public QObject +{ + Q_OBJECT + +public: + explicit FileProviderService(QObject *parent = nullptr); + ~FileProviderService() override; + + /** + * @brief Get the Objective-C delegate object that implements AppProtocol. + * @return The delegate pointer (void* to avoid Objective-C in header). + */ + [[nodiscard]] void *delegate() const; + + /** + * @brief Get the latest received sync status for a given account. + * @param account The account for which to get the sync status. + * @return The latest sync status, or SyncResult::Undefined if not known. + */ + [[nodiscard]] SyncResult::Status latestReceivedSyncStatusForAccount(const AccountPtr &account) const; + + /** + * @brief Set the latest received sync status for a user. + * @param userId The user ID at host with port. + * @param status The sync status to set. + */ + void setLatestReceivedSyncStatus(const QString &userId, SyncResult::Status status); + +signals: + /** + * @brief Emitted when a file provider extension reports its sync status. + * @param account The account for which the sync state changed. + * @param state The new sync state. + */ + void syncStateChanged(const AccountPtr &account, SyncResult::Status state); + +private: + class MacImplementation; + std::unique_ptr d; + + mutable QReadWriteLock _syncStatusLock; + QHash _latestReceivedSyncStatus; +}; + +} // namespace Mac + +} // namespace OCC + diff --git a/src/gui/macOS/fileproviderservice.mm b/src/gui/macOS/fileproviderservice.mm new file mode 100644 index 0000000000000..5840f44619b09 --- /dev/null +++ b/src/gui/macOS/fileproviderservice.mm @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "fileproviderservice.h" + +#import +#import "AppProtocol.h" + +#include +#include +#include +#include + +#include "accountmanager.h" + +namespace OCC { + +Q_LOGGING_CATEGORY(lcMacFileProviderService, "nextcloud.gui.macfileproviderservice", QtInfoMsg) + +} // namespace OCC + +/** + * @brief Objective-C delegate that implements the AppProtocol. + */ +@interface FileProviderServiceDelegate : NSObject +@property (nonatomic, assign) OCC::Mac::FileProviderService *service; +@end + +@implementation FileProviderServiceDelegate + +- (void)reportSyncStatus:(NSString *)status forDomainIdentifier:(NSString *)domainIdentifier +{ + const auto statusString = QString::fromNSString(status); + const auto domainIdString = QString::fromNSString(domainIdentifier); + + qCDebug(OCC::lcMacFileProviderService) << "Received sync status from file provider extension:" + << statusString + << "for domain:" << domainIdString; + + if (!_service) { + qCWarning(OCC::lcMacFileProviderService) << "No service available to report sync state"; + return; + } + + const auto accountState = OCC::AccountManager::instance()->accountFromFileProviderDomainIdentifier(domainIdString); + if (!accountState) { + qCWarning(OCC::lcMacFileProviderService) << "No account state found for domain identifier:" << domainIdString; + return; + } + + const auto account = accountState->account(); + if (!account) { + qCWarning(OCC::lcMacFileProviderService) << "No account found for domain identifier:" << domainIdString; + return; + } + + auto syncState = OCC::SyncResult::Status::Undefined; + if (statusString == QStringLiteral("SYNC_PREPARING")) { + syncState = OCC::SyncResult::Status::SyncPrepare; + } else if (statusString == QStringLiteral("SYNC_STARTED")) { + syncState = OCC::SyncResult::Status::SyncRunning; + } else if (statusString == QStringLiteral("SYNC_FINISHED")) { + syncState = OCC::SyncResult::Status::Success; + } else if (statusString == QStringLiteral("SYNC_FAILED")) { + syncState = OCC::SyncResult::Status::Problem; + } else if (statusString == QStringLiteral("SYNC_PAUSED")) { + syncState = OCC::SyncResult::Status::Paused; + } else { + qCWarning(OCC::lcMacFileProviderService) << "Unknown sync state received:" << statusString; + } + + const auto userId = account->userIdAtHostWithPort(); + qCDebug(OCC::lcMacFileProviderService) << "Received sync state change for account" << userId << "state" << syncState; + _service->setLatestReceivedSyncStatus(userId, syncState); + + // Use QMetaObject::invokeMethod to emit the signal on the correct thread + // since this callback may be called on an XPC dispatch queue (non-main thread) + QMetaObject::invokeMethod(_service, "syncStateChanged", Qt::QueuedConnection, + Q_ARG(OCC::AccountPtr, account), + Q_ARG(OCC::SyncResult::Status, syncState)); +} + +@end + +namespace OCC { + +namespace Mac { + +class FileProviderService::MacImplementation +{ +public: + FileProviderServiceDelegate *delegate = nil; + + MacImplementation() + { + qCDebug(lcMacFileProviderService) << "Initializing file provider service"; + } + + ~MacImplementation() + { + [delegate release]; + delegate = nil; + } +}; + +FileProviderService::FileProviderService(QObject *parent) + : QObject(parent) + , d(std::make_unique()) +{ + qCDebug(lcMacFileProviderService) << "FileProviderService created"; + + d->delegate = [[FileProviderServiceDelegate alloc] init]; + d->delegate.service = this; +} + +FileProviderService::~FileProviderService() +{ + qCDebug(lcMacFileProviderService) << "FileProviderService destroyed"; +} + +void *FileProviderService::delegate() const +{ + return d ? d->delegate : nullptr; +} + +SyncResult::Status FileProviderService::latestReceivedSyncStatusForAccount(const AccountPtr &account) const +{ + Q_ASSERT(account); + QReadLocker locker(&_syncStatusLock); + return _latestReceivedSyncStatus.value(account->userIdAtHostWithPort(), SyncResult::Undefined); +} + +void FileProviderService::setLatestReceivedSyncStatus(const QString &userId, SyncResult::Status status) +{ + QWriteLocker locker(&_syncStatusLock); + _latestReceivedSyncStatus.insert(userId, status); +} + +} // namespace Mac + +} // namespace OCC + diff --git a/src/gui/macOS/fileprovidersocketcontroller.cpp b/src/gui/macOS/fileprovidersocketcontroller.cpp index 11ee78780373d..38ebdf3b0bb90 100644 --- a/src/gui/macOS/fileprovidersocketcontroller.cpp +++ b/src/gui/macOS/fileprovidersocketcontroller.cpp @@ -96,11 +96,6 @@ void FileProviderSocketController::parseReceivedLine(const QString &receivedLine _accountState = AccountManager::instance()->accountFromFileProviderDomainIdentifier(domainIdentifier); sendIgnoreList(); sendAccountDetails(); - reportSyncState("SYNC_FINISHED"); - return; - } else if (command == "FILE_PROVIDER_DOMAIN_SYNC_STATE_CHANGE") { - qCDebug(lcFileProviderSocketController) << "Received FILE_PROVIDER_DOMAIN_SYNC_STATE_CHANGE:" << argument; - reportSyncState(argument); return; } @@ -262,30 +257,6 @@ void FileProviderSocketController::sendIgnoreList() const sendMessage(message); } -void FileProviderSocketController::reportSyncState(const QString &receivedState) const -{ - if (!accountState()) { - qCWarning(lcFileProviderSocketController) << "No account state available to report sync state"; - return; - } - - auto syncState = SyncResult::Status::Undefined; - if (receivedState == "SYNC_PREPARING") { - syncState = SyncResult::Status::SyncPrepare; - } else if (receivedState == "SYNC_STARTED") { - syncState = SyncResult::Status::SyncRunning; - } else if (receivedState == "SYNC_FINISHED") { - syncState = SyncResult::Status::Success; - } else if (receivedState == "SYNC_FAILED") { - syncState = SyncResult::Status::Problem; - } else if (receivedState == "SYNC_PAUSED") { - syncState = SyncResult::Status::Paused; - } else { - qCWarning(lcFileProviderSocketController) << "Unknown sync state received:" << receivedState; - } - emit syncStateChanged(_accountState->account(), syncState); -} - } // namespace Mac } // namespace OCC diff --git a/src/gui/macOS/fileprovidersocketcontroller.h b/src/gui/macOS/fileprovidersocketcontroller.h index 5438d5fbea686..c7283c9a75cf4 100644 --- a/src/gui/macOS/fileprovidersocketcontroller.h +++ b/src/gui/macOS/fileprovidersocketcontroller.h @@ -8,7 +8,6 @@ #include #include "gui/accountstate.h" -#include "libsync/syncresult.h" class QLocalSocket; @@ -27,7 +26,6 @@ class FileProviderSocketController : public QObject signals: void socketDestroyed(const QLocalSocket * const socket); - void syncStateChanged(const AccountPtr &account, SyncResult::Status state) const; public slots: void sendMessage(const QString &message) const; @@ -46,8 +44,6 @@ private slots: void sendNotAuthenticated() const; void sendIgnoreList() const; - void reportSyncState(const QString &receivedState) const; - private: QPointer _socket; AccountStatePtr _accountState; diff --git a/src/gui/macOS/fileprovidersocketserver.cpp b/src/gui/macOS/fileprovidersocketserver.cpp index 0d95228efa4e3..2b661f4250849 100644 --- a/src/gui/macOS/fileprovidersocketserver.cpp +++ b/src/gui/macOS/fileprovidersocketserver.cpp @@ -8,8 +8,6 @@ #include #include -#include "libsync/account.h" - #include "fileprovidersocketcontroller.h" namespace OCC { @@ -64,8 +62,6 @@ void FileProviderSocketServer::slotNewConnection() } const FileProviderSocketControllerPtr socketController(new FileProviderSocketController(socket, this)); - connect(socketController.data(), &FileProviderSocketController::syncStateChanged, - this, &FileProviderSocketServer::slotSyncStateChanged); connect(socketController.data(), &FileProviderSocketController::socketDestroyed, this, &FileProviderSocketServer::slotSocketDestroyed); _socketControllers.insert(socket, socketController); @@ -83,21 +79,6 @@ void FileProviderSocketServer::slotSocketDestroyed(const QLocalSocket * const so } } -void FileProviderSocketServer::slotSyncStateChanged(const AccountPtr &account, SyncResult::Status state) -{ - Q_ASSERT(account); - const auto userId = account->userIdAtHostWithPort(); - qCDebug(lcFileProviderSocketServer) << "Received sync state change for account" << userId << "state" << state; - _latestReceivedSyncStatus.insert(userId, state); - Q_EMIT syncStateChanged(account, state); -} - -SyncResult::Status FileProviderSocketServer::latestReceivedSyncStatusForAccount(const AccountPtr &account) const -{ - Q_ASSERT(account); - return _latestReceivedSyncStatus.value(account->userIdAtHostWithPort(), SyncResult::Undefined); -} - } // namespace Mac } // namespace OCC diff --git a/src/gui/macOS/fileprovidersocketserver.h b/src/gui/macOS/fileprovidersocketserver.h index 2397312bab912..d01bd941aba9d 100644 --- a/src/gui/macOS/fileprovidersocketserver.h +++ b/src/gui/macOS/fileprovidersocketserver.h @@ -7,9 +7,9 @@ #include #include - -#include "libsync/accountfwd.h" -#include "libsync/syncresult.h" +#include +#include +#include namespace OCC { @@ -40,22 +40,15 @@ class FileProviderSocketServer : public QObject public: explicit FileProviderSocketServer(QObject *parent = nullptr); - [[nodiscard]] SyncResult::Status latestReceivedSyncStatusForAccount(const AccountPtr &account) const; - -signals: - void syncStateChanged(const AccountPtr &account, SyncResult::Status state) const; - private slots: void startListening(); void slotNewConnection(); void slotSocketDestroyed(const QLocalSocket * const socket); - void slotSyncStateChanged(const AccountPtr &account, SyncResult::Status state); private: QString _socketPath; QLocalServer _socketServer; QHash _socketControllers; - QHash _latestReceivedSyncStatus; }; } // namespace Mac diff --git a/src/gui/macOS/fileproviderxpc_mac.mm b/src/gui/macOS/fileproviderxpc_mac.mm index cf5bb41134308..ccd2ae467fb76 100644 --- a/src/gui/macOS/fileproviderxpc_mac.mm +++ b/src/gui/macOS/fileproviderxpc_mac.mm @@ -40,7 +40,10 @@ const auto managers = FileProviderXPCUtils::getDomainManagers(); const auto fpServices = FileProviderXPCUtils::getFileProviderServices(managers); const auto connections = FileProviderXPCUtils::connectToFileProviderServices(fpServices); - _clientCommServices = FileProviderXPCUtils::processClientCommunicationConnections(connections); + + // Get the FileProviderService singleton from FileProvider + const auto fileProviderService = FileProvider::instance()->service(); + _clientCommServices = FileProviderXPCUtils::processClientCommunicationConnections(connections, fileProviderService); } void FileProviderXPC::authenticateFileProviderDomains() @@ -137,6 +140,7 @@ } const auto service = (NSObject *)_clientCommServices.value(fileProviderDomainIdentifier); + if (service == nil) { qCWarning(lcFileProviderXPC) << "Could not get service for file provider domain" << fileProviderDomainIdentifier; return false; @@ -171,7 +175,8 @@ const auto manager = [NSFileProviderManager managerForDomain:domain]; const auto fpServices = FileProviderXPCUtils::getFileProviderServices(@[manager]); const auto connections = FileProviderXPCUtils::connectToFileProviderServices(fpServices); - const auto services = FileProviderXPCUtils::processClientCommunicationConnections(connections); + const auto fileProviderService = FileProvider::instance()->service(); + const auto services = FileProviderXPCUtils::processClientCommunicationConnections(connections, fileProviderService); _clientCommServices.insert(services); } diff --git a/src/gui/macOS/fileproviderxpc_mac_utils.h b/src/gui/macOS/fileproviderxpc_mac_utils.h index 746666a5ccf55..984e754c397f7 100644 --- a/src/gui/macOS/fileproviderxpc_mac_utils.h +++ b/src/gui/macOS/fileproviderxpc_mac_utils.h @@ -9,6 +9,7 @@ #import #import "ClientCommunicationProtocol.h" +#import "fileproviderservice.h" namespace OCC::Mac::FileProviderXPCUtils { @@ -25,6 +26,6 @@ NSObject *getRemoteServiceObject(NSXPCConnection *connection, Protocol *protocol */ NSString *getFileProviderDomainIdentifier(NSObject *clientCommService); -QHash processClientCommunicationConnections(NSArray *connections); +QHash processClientCommunicationConnections(NSArray *connections, OCC::Mac::FileProviderService *service); } diff --git a/src/gui/macOS/fileproviderxpc_mac_utils.mm b/src/gui/macOS/fileproviderxpc_mac_utils.mm index 6ccc86c418bb7..ff63f67c7b1db 100644 --- a/src/gui/macOS/fileproviderxpc_mac_utils.mm +++ b/src/gui/macOS/fileproviderxpc_mac_utils.mm @@ -5,6 +5,9 @@ #include "fileproviderxpc_mac_utils.h" +#import "AppProtocol.h" +#import "fileprovider.h" + #include #include "gui/accountmanager.h" @@ -251,12 +254,22 @@ void configureFileProviderConnection(NSXPCConnection *const connection) return domainIdentifier; } -QHash processClientCommunicationConnections(NSArray *const connections) +QHash processClientCommunicationConnections(NSArray *const connections, OCC::Mac::FileProviderService *const service) { QHash clientCommServices; for (NSXPCConnection * const connection in connections) { + const auto exportedInterfaceProtocol = @protocol(AppProtocol); const auto remoteObjectInterfaceProtocol = @protocol(ClientCommunicationProtocol); + connection.exportedInterface = [NSXPCInterface interfaceWithProtocol:exportedInterfaceProtocol]; + + // Set the FileProviderService delegate as the exported object + if (service) { + connection.exportedObject = (id)service->delegate(); + } else { + qCWarning(lcFileProviderXPCUtils) << "FileProviderService is null, cannot set exported object"; + } + connection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:remoteObjectInterfaceProtocol]; configureFileProviderConnection(connection); diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index 75735efab7a5f..68432164f2b1a 100644 --- a/src/gui/owncloudgui.cpp +++ b/src/gui/owncloudgui.cpp @@ -63,6 +63,7 @@ #ifdef BUILD_FILE_PROVIDER_MODULE #include "macOS/fileprovider.h" #include "macOS/fileproviderdomainmanager.h" +#include "macOS/fileproviderservice.h" #include "macOS/fileprovidersettingscontroller.h" #endif @@ -114,7 +115,7 @@ ownCloudGui::ownCloudGui(Application *parent) #ifdef BUILD_FILE_PROVIDER_MODULE - connect(Mac::FileProvider::instance()->socketServer(), &Mac::FileProviderSocketServer::syncStateChanged, this, &ownCloudGui::slotComputeOverallSyncStatus); + connect(Mac::FileProvider::instance()->service(), &Mac::FileProviderService::syncStateChanged, this, &ownCloudGui::slotComputeOverallSyncStatus); #endif connect(Logger::instance(), &Logger::guiLog, this, &ownCloudGui::slotShowTrayMessage); @@ -327,7 +328,7 @@ void ownCloudGui::slotComputeOverallSyncStatus() if (!fileProvider->xpc()->fileProviderDomainReachable(accountFpId)) { problemFileProviderAccounts.append(accountTooltipLabel); } else { - switch (fileProvider->socketServer()->latestReceivedSyncStatusForAccount(accountState->account())) { + switch (fileProvider->service()->latestReceivedSyncStatusForAccount(accountState->account())) { case SyncResult::Undefined: case SyncResult::NotYetStarted: idleFileProviderAccounts.append(accountTooltipLabel); diff --git a/src/gui/tray/syncstatussummary.cpp b/src/gui/tray/syncstatussummary.cpp index 23f8c219ce86a..2e06017ca7621 100644 --- a/src/gui/tray/syncstatussummary.cpp +++ b/src/gui/tray/syncstatussummary.cpp @@ -14,7 +14,7 @@ #ifdef BUILD_FILE_PROVIDER_MODULE #include "gui/macOS/fileprovider.h" -#include "gui/macOS/fileprovidersocketserver.h" +#include "gui/macOS/fileproviderservice.h" #include "gui/macOS/fileprovidersettingscontroller.h" #endif @@ -49,7 +49,7 @@ SyncStatusSummary::SyncStatusSummary(QObject *parent) connect(folderMan, &FolderMan::folderListChanged, this, &SyncStatusSummary::onFolderListChanged); connect(folderMan, &FolderMan::folderSyncStateChange, this, &SyncStatusSummary::onFolderSyncStateChanged); #ifdef BUILD_FILE_PROVIDER_MODULE - connect(Mac::FileProvider::instance()->socketServer(), &Mac::FileProviderSocketServer::syncStateChanged, this, &SyncStatusSummary::onFileProviderDomainSyncStateChanged); + connect(Mac::FileProvider::instance()->service(), &Mac::FileProviderService::syncStateChanged, this, &SyncStatusSummary::onFileProviderDomainSyncStateChanged); #endif } @@ -439,7 +439,7 @@ void SyncStatusSummary::initSyncState() const auto userIdAtHostWithPort = account->userIdAtHostWithPort(); if (Mac::FileProviderSettingsController::instance()->vfsEnabledForAccount(userIdAtHostWithPort)) { - const auto lastKnownSyncState = Mac::FileProvider::instance()->socketServer()->latestReceivedSyncStatusForAccount(account); + const auto lastKnownSyncState = Mac::FileProvider::instance()->service()->latestReceivedSyncStatusForAccount(account); onFileProviderDomainSyncStateChanged(account, lastKnownSyncState); syncStateFallbackNeeded = false; } From 44fe7d943b87e084199a5990b56cf759d6db60dd Mon Sep 17 00:00:00 2001 From: Iva Horn Date: Tue, 20 Jan 2026 11:24:27 +0100 Subject: [PATCH 2/3] fix(file-provider): Do not report an error for files excluded from synchronization. Signed-off-by: Iva Horn --- .../FileProviderExt/FileProviderExtension.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension.swift index 044ba1ba87619..1bc1d276b6973 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension.swift +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension.swift @@ -303,11 +303,11 @@ import OSLog if error == nil { removeSyncAction(actionId) } else { - // Do not consider the exclusion of a lock file a synchronization error resulting in a misleading status report because exclusion is expected. + // Do not consider the exclusion of a file a synchronization error resulting in a misleading status report because exclusion is expected. // Though, the exclusion error code is only available starting with macOS 13, hence this logic reads a bit more cumbersome. if #available(macOS 13.0, *) { - if isLockFileName(itemTemplate.filename), let fileProviderError = error as? NSFileProviderError, fileProviderError.code == .excludedFromSync { + if let fileProviderError = error as? NSFileProviderError, fileProviderError.code == .excludedFromSync { removeSyncAction(actionId) } else { insertErrorAction(actionId) @@ -725,7 +725,6 @@ import OSLog logger.debug("Reporting synchronization state.", [.name: argument]) - let message = command + ":" + argument + "\n" - app?.reportSyncStatus(message) + app?.reportSyncStatus(argument, forDomainIdentifier: domain.identifier.rawValue) } } From 36cb343e7b8c2c0d07f5a3ef8232e164b94c42a0 Mon Sep 17 00:00:00 2001 From: Iva Horn Date: Tue, 20 Jan 2026 09:07:02 +0100 Subject: [PATCH 3/3] chore: Updated AGENTS.md Signed-off-by: Iva Horn --- AGENTS.md | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cad9060a0a3d3..e84ee1ad9ec56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,27 +3,56 @@ - SPDX-License-Identifier: GPL-2.0-or-later --> # Agents.md -This `AGENTS.md` file provides guidelines for OpenAI Codex and other AI agents interacting with this codebase, including which directories are safe to read from or write to. + +You are an experienced engineer specialized on C++ and Qt and familiar with the platform-specific details of Windows, macOS and Linux. + +## Your Role + +- You implement features and fix bugs. +- Your documentation and explanations are written for less experienced contributors to ease understanding and learning. +- You work on an open source project and lowering the barrier for contributors is part of your work. ## Project Overview + The Nextcloud Desktop Client is a tool to synchronize files from Nextcloud Server with your computer. +Qt, C++, CMake and KDE Craft are the key technologies used for building the app on Windows, macOS and Linux. +Beyond that, there are platform-specific extensions of the multi-platform app in the `./shell_integration` directory. ## Project Structure: AI Agent Handling Guidelines | Directory | Description | Agent Action | |-----------------|-----------------------------------------------------|----------------------| -| `/translations` | Translation files from Transifex. | Do not modify | +| `./admin/osx/mac-crafter` | Build tool for macOS | Ignore unless the build process must be updated | +| `./shell_integration/MacOSX/NextcloudIntegration` | Xcode project for macOS app extensions | Look here first for changes in context of the file provider extension | +| `./translations` | Translation files from Transifex. | Do not modify | ## General Guidance Every new file needs to get a SPDX header in the first rows according to this template. -The year needs to be adjusted accordingly. The commenting signs need to be used depending on the file type. -``` -SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +The year in the first line must be replaced with the year when the file is created (for example, 2026 for files first added in 2026). +The commenting signs need to be used depending on the file type. + +```plaintext +SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors SPDX-License-Identifier: GPL-2.0-or-later ``` -## Commit & PR Guidelines +## Commit and Pull Request Guidelines + - **Commits**: Follow Conventional Commits format. Use `feat: ...`, `fix: ...`, or `refactor: ...` as appropriate in the commit message prefix. - Include a short summary of what changed. *Example:* `fix: prevent crash on empty todo title`. - **Pull Request**: When the agent creates a PR, it should include a description summarizing the changes and why they were made. If a GitHub issue exists, reference it (e.g., “Closes #123”). + +## macOS Specifics + +The following details are important when working on the desktop client on macOS. + +- Latest stable Xcode available is required to be installed in the development environment. +- There is a self-contained and independent build tool called mac-crafter in `./admin/osx/mac-crafter` implemented as a Swift package which builds as an executable. +- To enable a macOS app build, the file `./shell_integration/MacOSX/NextcloudIntegration/NextcloudDev/Build.xcconfig` must be created if not existent already and it must contain the Xcode build setting `CODE_SIGN_IDENTITY=Apple Development`. +- To verify that the project builds successfully on macOS, mac-crafter can be run in its own directory with these arguments: `swift run mac-crafter --build-path=DerivedData --product-path=/Applications --build-type=Debug --dev --disable-auto-updater --build-file-provider-module` +- The macOS app includes a FinderSync extension. +- The macOS app can be built to include a file provider extension and file provider UI extension. +- The macOS extensions bundled with the main app are built in the Xcode project in `./shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj`. The build system later copies the built extension bundles into the main app bundle on its own. The Xcode project does not build the main app. +- The main app manages file provider domains and the communication with them via XPC in source code files located in `./src/gui/macOS` and usually are written in Objective-C++ (implementation files with `.mm` extension, sometimes having a `_mac` suffix in their name while their corresponding header files do not). The PIMPL pattern is an established convention here. +- When writing code in Swift, respect strict concurrency rules and Swift 6 compatibility. \ No newline at end of file