From b9047f1aec610b97406e431576d56066e4ffadbf Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 27 May 2025 14:13:49 +0800 Subject: [PATCH 01/15] Purge currentCapabilitiesSync Signed-off-by: Claudio Cambra --- .../NextcloudKit+RemoteInterface.swift | 17 ----------------- .../Interface/RemoteInterface.swift | 2 -- .../NextcloudFileProviderKit/Item/Item.swift | 8 ++++---- Tests/Interface/MockRemoteInterface.swift | 12 ------------ 4 files changed, 4 insertions(+), 35 deletions(-) diff --git a/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift b/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift index b145084b..326743a9 100644 --- a/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift +++ b/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift @@ -431,23 +431,6 @@ extension NextcloudKit: RemoteInterface { return (account.ncKitAccount, lastRetrieval.capabilities, nil, .success) } - public func currentCapabilitiesSync(account: Account) -> Capabilities? { - let semaphore = DispatchSemaphore(value: 0) - var capabilities: Capabilities? - Task { - let (_, fetchedCapabilities, _, error) = await currentCapabilities(account: account) - if error != .success { - Logger - .init(subsystem: Logger.subsystem, category: "NextcloudKitRemoteInterface") - .error("Error during sync capabilities fetch: \(error.errorDescription, privacy: .public)") - } - capabilities = fetchedCapabilities - semaphore.signal() - } - semaphore.wait() - return capabilities - } - public func fetchUserProfile( account: Account, options: NKRequestOptions = .init(), diff --git a/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift b/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift index e906ceca..cef51043 100644 --- a/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift +++ b/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift @@ -165,8 +165,6 @@ public protocol RemoteInterface { taskHandler: @escaping (_ task: URLSessionTask) -> Void ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) - func currentCapabilitiesSync(account: Account) -> Capabilities? - func fetchUserProfile( account: Account, options: NKRequestOptions, diff --git a/Sources/NextcloudFileProviderKit/Item/Item.swift b/Sources/NextcloudFileProviderKit/Item/Item.swift index 614b4a31..4c902faf 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item.swift @@ -29,6 +29,8 @@ public class Item: NSObject, NSFileProviderItem { public let account: Account public let remoteInterface: RemoteInterface + private let serverSupportsTrash: Bool = false + public var itemIdentifier: NSFileProviderItemIdentifier { NSFileProviderItemIdentifier(metadata.ocId) } @@ -55,7 +57,7 @@ public class Item: NSObject, NSFileProviderItem { directoryCapabilities.insert(.allowsEvicting) } - if remoteInterface.currentCapabilitiesSync(account: account)?.files?.undelete == true { + if serverSupportsTrash { directoryCapabilities.insert(.allowsTrashing) } @@ -73,9 +75,7 @@ public class Item: NSObject, NSFileProviderItem { .allowsReparenting, .allowsEvicting, ] - if remoteInterface.currentCapabilitiesSync(account: account)?.files?.undelete == true, - !isLockFileName(filename) - { + if serverSupportsTrash, !isLockFileName(filename) { itemCapabilities.insert(.allowsTrashing) } return itemCapabilities diff --git a/Tests/Interface/MockRemoteInterface.swift b/Tests/Interface/MockRemoteInterface.swift index add2fcc2..f166246d 100644 --- a/Tests/Interface/MockRemoteInterface.swift +++ b/Tests/Interface/MockRemoteInterface.swift @@ -1150,18 +1150,6 @@ public class MockRemoteInterface: RemoteInterface { return await fetchCapabilities(account: account, options: options, taskHandler: taskHandler) } - public func currentCapabilitiesSync(account: Account) -> Capabilities? { - let semaphore = DispatchSemaphore(value: 0) - var capabilities: Capabilities? - Task { - let result = await currentCapabilities(account: account) - capabilities = result.capabilities - semaphore.signal() - } - semaphore.wait() - return capabilities - } - public func fetchUserProfile( account: Account, options: NKRequestOptions = .init(), From 5c2337bf2531ee12bb95ec8859cc829b8fcec663 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 27 May 2025 14:18:36 +0800 Subject: [PATCH 02/15] Add convenience remote interface extension func to get if remote interface supports trash Signed-off-by: Claudio Cambra --- .../Interface/RemoteInterface.swift | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift b/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift index cef51043..9a795b1f 100644 --- a/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift +++ b/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift @@ -10,6 +10,7 @@ import FileProvider import Foundation import NextcloudCapabilitiesKit import NextcloudKit +import OSLog public enum EnumerateDepth: String { case target = "0" @@ -177,3 +178,27 @@ public protocol RemoteInterface { taskHandler: @escaping (_ task: URLSessionTask) -> Void ) async -> AuthenticationAttemptResultState } + +public extension RemoteInterface { + + private var logger: Logger { + Logger(subsystem: Logger.subsystem, category: "RemoteInterface") + } + + func supportsTrash( + account: Account, + options: NKRequestOptions = .init(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> Bool { + var serverSupportsTrash = false + let (_, capabilities, _, error) = await currentCapabilities( + account: account, options: .init(), taskHandler: { _ in } + ) + if let filesCapabilities = capabilities?.files { + serverSupportsTrash = filesCapabilities.undelete + } else { + logger.warning("Could not get capabilities, will assume trash is unavailable.") + } + return serverSupportsTrash + } +} From 12b00df39ff1405fe5b69459409e671d0ec36d68 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 27 May 2025 14:37:35 +0800 Subject: [PATCH 03/15] Add bypass method to mock interface to directly, synchronously acquire capabilities Signed-off-by: Claudio Cambra --- Tests/Interface/Item+Init.swift | 0 Tests/Interface/MockRemoteInterface.swift | 7 ++++++- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 Tests/Interface/Item+Init.swift diff --git a/Tests/Interface/Item+Init.swift b/Tests/Interface/Item+Init.swift new file mode 100644 index 00000000..e69de29b diff --git a/Tests/Interface/MockRemoteInterface.swift b/Tests/Interface/MockRemoteInterface.swift index f166246d..beb9e283 100644 --- a/Tests/Interface/MockRemoteInterface.swift +++ b/Tests/Interface/MockRemoteInterface.swift @@ -1139,7 +1139,7 @@ public class MockRemoteInterface: RemoteInterface { taskHandler: @escaping (URLSessionTask) -> Void ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) { let capsData = capabilities.data(using: .utf8) - return (account.ncKitAccount, Capabilities(data: capsData ?? Data()), capsData, .success) + return (account.ncKitAccount, directMockCapabilities(), capsData, .success) } public func currentCapabilities( @@ -1150,6 +1150,11 @@ public class MockRemoteInterface: RemoteInterface { return await fetchCapabilities(account: account, options: options, taskHandler: taskHandler) } + public func directMockCapabilities() -> Capabilities? { + let capsData = capabilities.data(using: .utf8) + return Capabilities(data: capsData ?? Data()) + } + public func fetchUserProfile( account: Account, options: NKRequestOptions = .init(), From f13398a15c0752b6fc31869305f9d50b38ea3e54 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 27 May 2025 14:37:58 +0800 Subject: [PATCH 04/15] Require MockRemoteInterface in MockEnumerator Signed-off-by: Claudio Cambra --- Tests/Interface/MockEnumerator.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/Interface/MockEnumerator.swift b/Tests/Interface/MockEnumerator.swift index c0af1ac6..2cab306b 100644 --- a/Tests/Interface/MockEnumerator.swift +++ b/Tests/Interface/MockEnumerator.swift @@ -12,11 +12,11 @@ import NextcloudFileProviderKit public class MockEnumerator: NSObject, NSFileProviderEnumerator { let account: Account let dbManager: FilesDatabaseManager - let remoteInterface: any RemoteInterface + let remoteInterface: MockRemoteInterface public var enumeratorItems: [SendableItemMetadata] = [] public init( - account: Account, dbManager: FilesDatabaseManager, remoteInterface: any RemoteInterface + account: Account, dbManager: FilesDatabaseManager, remoteInterface: MockRemoteInterface ) { self.account = account self.dbManager = dbManager From 97705245f6eaa2128b143eb62b5bb2049335da3e Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 27 May 2025 14:41:33 +0800 Subject: [PATCH 05/15] Require providing if remote supports trash in Item constructor Signed-off-by: Claudio Cambra --- .../Enumeration/Enumerator+SyncEngine.swift | 5 ++- .../Enumeration/Enumerator+Trash.swift | 5 ++- .../Enumeration/Enumerator.swift | 2 +- .../Interface/RemoteInterface.swift | 8 ++-- .../Item/Item+Create.swift | 14 ++++--- .../Item/Item+Fetch.swift | 3 +- .../Item/Item+Ignored.swift | 5 ++- .../Item/Item+LockFile.swift | 5 ++- .../Item/Item+Modify.swift | 11 ++++-- .../Item/Item+Trash.swift | 9 +++-- .../Item/Item+Unuploaded.swift | 5 ++- .../NextcloudFileProviderKit/Item/Item.swift | 38 +++++++++++++------ .../Metadata/SendableItemMetadata+Array.swift | 4 +- Tests/Interface/Item+Init.swift | 29 ++++++++++++++ Tests/Interface/MockEnumerator.swift | 4 +- .../EnumeratorTests.swift | 5 ++- .../ItemPropertyTests.swift | 5 +-- 17 files changed, 113 insertions(+), 44 deletions(-) diff --git a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift index 5b844c50..a54abe80 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift @@ -31,7 +31,10 @@ extension Enumerator { ) { let results = await self.scanRecursively( Item.rootContainer( - account: account, remoteInterface: remoteInterface, dbManager: dbManager + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ).metadata, account: account, remoteInterface: remoteInterface, diff --git a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift index db3bbba6..853a2781 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift @@ -48,7 +48,7 @@ extension Enumerator { remoteInterface: RemoteInterface, dbManager: FilesDatabaseManager, trashItems: [NKTrash] - ) { + ) async { var newTrashedItems = [NSFileProviderItem]() // NKTrash items do not have an etag ; we assume they cannot be modified while they are in @@ -71,7 +71,8 @@ extension Enumerator { parentItemIdentifier: .trashContainer, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) newTrashedItems.append(item) diff --git a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift index 4faa1a62..05f98474 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift @@ -451,7 +451,7 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { return } - Self.completeChangesObserver( + await Self.completeChangesObserver( observer, anchor: anchor, account: account, diff --git a/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift b/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift index 9a795b1f..bd821b4a 100644 --- a/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift +++ b/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift @@ -180,7 +180,7 @@ public protocol RemoteInterface { } public extension RemoteInterface { - + private var logger: Logger { Logger(subsystem: Logger.subsystem, category: "RemoteInterface") } @@ -190,15 +190,15 @@ public extension RemoteInterface { options: NKRequestOptions = .init(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } ) async -> Bool { - var serverSupportsTrash = false + var remoteSupportsTrash = false let (_, capabilities, _, error) = await currentCapabilities( account: account, options: .init(), taskHandler: { _ in } ) if let filesCapabilities = capabilities?.files { - serverSupportsTrash = filesCapabilities.undelete + remoteSupportsTrash = filesCapabilities.undelete } else { logger.warning("Could not get capabilities, will assume trash is unavailable.") } - return serverSupportsTrash + return remoteSupportsTrash } } diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Create.swift b/Sources/NextcloudFileProviderKit/Item/Item+Create.swift index 1cab9b4f..e3b40d82 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Create.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Create.swift @@ -99,7 +99,8 @@ public extension Item { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) return (fpItem, nil) @@ -216,7 +217,8 @@ public extension Item { parentItemIdentifier: itemTemplate.parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) return (fpItem, nil) @@ -412,7 +414,8 @@ public extension Item { parentItemIdentifier: rootItem.parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) } @@ -495,7 +498,7 @@ public extension Item { let relativePath = parentItemRelativePath + "/" + itemTemplate.filename guard ignoredFiles == nil || ignoredFiles?.isExcluded(relativePath) == false else { - return Item.createIgnored( + return await Item.createIgnored( basedOn: itemTemplate, parentItemRemotePath: parentItemRemotePath, contents: url, @@ -604,7 +607,8 @@ public extension Item { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) } diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Fetch.swift b/Sources/NextcloudFileProviderKit/Item/Item+Fetch.swift index de367313..df11d995 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Fetch.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Fetch.swift @@ -304,7 +304,8 @@ public extension Item { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) return (localPath, fpItem, nil) diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift b/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift index 3a1f6009..d9293b64 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift @@ -17,7 +17,7 @@ extension Item { remoteInterface: RemoteInterface, progress: Progress, dbManager: FilesDatabaseManager - ) -> (Item?, Error?) { + ) async -> (Item?, Error?) { let filename = itemTemplate.filename Self.logger.info( """ @@ -60,7 +60,8 @@ extension Item { parentItemIdentifier: itemTemplate.parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) if #available(macOS 13.0, *) { diff --git a/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift b/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift index 43e15a28..53c32c66 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift @@ -140,7 +140,8 @@ extension Item { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ), returnError ) @@ -167,7 +168,7 @@ extension Item { ) assert(isLockFileName(filename), "Should not handle non-lock files here.") - guard let modifiedItem = modifyUnuploaded( + guard let modifiedItem = await modifyUnuploaded( itemTarget: itemTarget, baseVersion: baseVersion, changedFields: changedFields, diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift b/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift index 7b01497d..87d3241b 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift @@ -88,7 +88,8 @@ public extension Item { parentItemIdentifier: newParentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) return (modifiedItem, nil) } @@ -224,7 +225,8 @@ public extension Item { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) return (modifiedItem, nil) } @@ -517,7 +519,8 @@ public extension Item { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) } @@ -565,7 +568,7 @@ public extension Item { Will delete locally with no remote effect. """ ) - guard let modifiedIgnored = modifyUnuploaded( + guard let modifiedIgnored = await modifyUnuploaded( itemTarget: itemTarget, baseVersion: baseVersion, changedFields: changedFields, diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift b/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift index 2eb89ff0..35c8a321 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift @@ -45,7 +45,8 @@ extension Item { parentItemIdentifier: .trashContainer, account: account, remoteInterface: modifiedItem.remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await modifiedItem.remoteInterface.supportsTrash(account: account) ) // The server may have renamed the trashed file so we need to scan the entire trash @@ -106,7 +107,8 @@ extension Item { parentItemIdentifier: .trashContainer, account: account, remoteInterface: modifiedItem.remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await modifiedItem.remoteInterface.supportsTrash(account: account) ) // Now we can directly update info on the child items @@ -197,7 +199,8 @@ extension Item { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: modifiedItem.remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await modifiedItem.remoteInterface.supportsTrash(account: account) ), nil) } diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Unuploaded.swift b/Sources/NextcloudFileProviderKit/Item/Item+Unuploaded.swift index a6a9719a..c9354432 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Unuploaded.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Unuploaded.swift @@ -75,7 +75,7 @@ extension Item { forcedChunkSize: Int? = nil, progress: Progress = .init(), dbManager: FilesDatabaseManager - ) -> Item? { + ) async -> Item? { var modifiedParentItemIdentifier = parentItemIdentifier var modifiedMetadata = metadata @@ -124,7 +124,8 @@ extension Item { parentItemIdentifier: modifiedParentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) } } diff --git a/Sources/NextcloudFileProviderKit/Item/Item.swift b/Sources/NextcloudFileProviderKit/Item/Item.swift index 4c902faf..b775f43d 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item.swift @@ -29,7 +29,7 @@ public class Item: NSObject, NSFileProviderItem { public let account: Account public let remoteInterface: RemoteInterface - private let serverSupportsTrash: Bool = false + private let remoteSupportsTrash: Bool public var itemIdentifier: NSFileProviderItemIdentifier { NSFileProviderItemIdentifier(metadata.ocId) @@ -57,7 +57,7 @@ public class Item: NSObject, NSFileProviderItem { directoryCapabilities.insert(.allowsEvicting) } - if serverSupportsTrash { + if remoteSupportsTrash { directoryCapabilities.insert(.allowsTrashing) } @@ -75,7 +75,7 @@ public class Item: NSObject, NSFileProviderItem { .allowsReparenting, .allowsEvicting, ] - if serverSupportsTrash, !isLockFileName(filename) { + if remoteSupportsTrash, !isLockFileName(filename) { itemCapabilities.insert(.allowsTrashing) } return itemCapabilities @@ -223,7 +223,8 @@ public class Item: NSObject, NSFileProviderItem { public static func rootContainer( account: Account, remoteInterface: RemoteInterface, - dbManager: FilesDatabaseManager + dbManager: FilesDatabaseManager, + remoteSupportsTrash: Bool ) -> Item { let metadata = SendableItemMetadata( ocId: NSFileProviderItemIdentifier.rootContainer.rawValue, @@ -256,14 +257,16 @@ public class Item: NSObject, NSFileProviderItem { parentItemIdentifier: .rootContainer, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash ) } public static func trashContainer( remoteInterface: RemoteInterface, account: Account, - dbManager: FilesDatabaseManager + dbManager: FilesDatabaseManager, + remoteSupportsTrash: Bool ) -> Item { let metadata = SendableItemMetadata( ocId: NSFileProviderItemIdentifier.trashContainer.rawValue, @@ -295,7 +298,8 @@ public class Item: NSObject, NSFileProviderItem { parentItemIdentifier: .trashContainer, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash ) } @@ -306,13 +310,15 @@ public class Item: NSObject, NSFileProviderItem { parentItemIdentifier: NSFileProviderItemIdentifier, account: Account, remoteInterface: RemoteInterface, - dbManager: FilesDatabaseManager + dbManager: FilesDatabaseManager, + remoteSupportsTrash: Bool ) { self.metadata = metadata self.parentItemIdentifier = parentItemIdentifier self.account = account self.remoteInterface = remoteInterface self.dbManager = dbManager + self.remoteSupportsTrash = remoteSupportsTrash super.init() } @@ -323,14 +329,23 @@ public class Item: NSObject, NSFileProviderItem { dbManager: FilesDatabaseManager ) async -> Item? { // resolve the given identifier to a record in the model + + let remoteSupportsTrash = await remoteInterface.supportsTrash(account: account) + guard identifier != .rootContainer else { return Item.rootContainer( - account: account, remoteInterface: remoteInterface, dbManager: dbManager + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash ) } guard identifier != .trashContainer else { return Item.trashContainer( - remoteInterface: remoteInterface, account: account, dbManager: dbManager + remoteInterface: remoteInterface, + account: account, + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash ) } @@ -355,7 +370,8 @@ public class Item: NSObject, NSFileProviderItem { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash ) } diff --git a/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift b/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift index b9de3670..26448afb 100644 --- a/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift +++ b/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift @@ -15,6 +15,7 @@ extension Array { let logger = Logger( subsystem: Logger.subsystem, category: "itemMetadataToFileProviderItems" ) + let remoteSupportsTrash = await remoteInterface.supportsTrash(account: account) return try await concurrentChunkedCompactMap { itemMetadata in guard !itemMetadata.e2eEncrypted else { @@ -57,7 +58,8 @@ extension Array { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash ) logger.debug( """ diff --git a/Tests/Interface/Item+Init.swift b/Tests/Interface/Item+Init.swift index e69de29b..01550a93 100644 --- a/Tests/Interface/Item+Init.swift +++ b/Tests/Interface/Item+Init.swift @@ -0,0 +1,29 @@ +// +// Item+Init.swift +// NextcloudFileProviderKit +// +// Created by Claudio Cambra on 27/5/25. +// + +import FileProvider +import Foundation +import NextcloudFileProviderKit + +public extension Item { + convenience init( + metadata: SendableItemMetadata, + parentItemIdentifier: NSFileProviderItemIdentifier, + account: Account, + remoteInterface: RemoteInterface, + dbManager: FilesDatabaseManager, + ) { + self.init( + metadata: metadata, + parentItemIdentifier: parentItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: true + ) + } +} diff --git a/Tests/Interface/MockEnumerator.swift b/Tests/Interface/MockEnumerator.swift index 2cab306b..238fff6e 100644 --- a/Tests/Interface/MockEnumerator.swift +++ b/Tests/Interface/MockEnumerator.swift @@ -26,6 +26,7 @@ public class MockEnumerator: NSObject, NSFileProviderEnumerator { public func enumerateItems( for observer: any NSFileProviderEnumerationObserver, startingAt page: NSFileProviderPage ) { + let remoteSupportsTrash = remoteInterface.directMockCapabilities()?.files?.undelete ?? false var items: [Item] = [] for item in enumeratorItems { guard let parentItemIdentifier = dbManager.parentItemIdentifierFromMetadata(item) else { @@ -37,7 +38,8 @@ public class MockEnumerator: NSObject, NSFileProviderEnumerator { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash ) items.append(item) } diff --git a/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift b/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift index 7571ca61..554ccfd5 100644 --- a/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift +++ b/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift @@ -591,7 +591,10 @@ final class EnumeratorTests: XCTestCase { ) let storedRootItem = Item.rootContainer( - account: Self.account, remoteInterface: remoteInterface, dbManager: Self.dbManager + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: Self.account) ) print(storedRootItem.metadata.serverUrl) XCTAssertEqual(storedRootItem.childItemCount?.intValue, 3) // All items diff --git a/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift b/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift index d46ce7f9..2e3d24a6 100644 --- a/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift +++ b/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift @@ -331,11 +331,10 @@ final class ItemPropertyTests: XCTestCase { XCTAssertFalse(item.capabilities.contains(.allowsTrashing)) } - func testItemTrashabilityAffectedByCapabilities() { + func testItemTrashabilityAffectedByCapabilities() async { let remoteInterface = MockRemoteInterface() XCTAssert(remoteInterface.capabilities.contains(##""undelete": true,"##)) - remoteInterface.capabilities = - remoteInterface.capabilities.replacingOccurrences(of: ##""undelete": true,"##, with: "") + let remoteSupportsTrash = await remoteInterface.supportsTrash(account: Self.account) let metadata = SendableItemMetadata(ocId: "test-id", fileName: "test", account: Self.account) let item = Item( From 872475cefdc64badc9ba86128bdddfb094b6d8c0 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 27 May 2025 14:43:20 +0800 Subject: [PATCH 06/15] Properly set up realm in Item property tests Signed-off-by: Claudio Cambra --- Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift b/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift index 2e3d24a6..d075d1dd 100644 --- a/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift +++ b/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift @@ -21,6 +21,11 @@ final class ItemPropertyTests: XCTestCase { realmConfig: .defaultConfiguration, account: account ) + override func setUp() { + super.setUp() + Realm.Configuration.defaultConfiguration.inMemoryIdentifier = name + } + func testMetadataContentType() { var metadata = SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account) From a04b0e079beef2da6564a4d5e534b22fe9e07163 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 27 May 2025 14:44:01 +0800 Subject: [PATCH 07/15] Add tests for trashability properties when using Item.storedItem Signed-off-by: Claudio Cambra --- .../ItemPropertyTests.swift | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift b/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift index d075d1dd..4a754349 100644 --- a/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift +++ b/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift @@ -347,9 +347,48 @@ final class ItemPropertyTests: XCTestCase { parentItemIdentifier: .rootContainer, account: Self.account, remoteInterface: remoteInterface, + dbManager: Self.dbManager, + remoteSupportsTrash: remoteSupportsTrash + ) + XCTAssertTrue(item.capabilities.contains(.allowsTrashing)) + } + + func testStoredItemTrashabilityFalseAffectedByCapabilities() async { + let db = Self.dbManager.ncDatabase() + debugPrint(db) + + let remoteInterface = MockRemoteInterface() + XCTAssert(remoteInterface.capabilities.contains(##""undelete": true,"##)) + remoteInterface.capabilities = + remoteInterface.capabilities.replacingOccurrences(of: ##""undelete": true,"##, with: "") + let metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test", account: Self.account) + Self.dbManager.addItemMetadata(metadata) + let item = await Item.storedItem( + identifier: .init(metadata.ocId), + account: Self.account, + remoteInterface: remoteInterface, dbManager: Self.dbManager ) - XCTAssertFalse(item.capabilities.contains(.allowsTrashing)) + XCTAssertEqual(item?.capabilities.contains(.allowsTrashing), false) + } + + func testStoredItemTrashabilityTrueAffectedByCapabilities() async { + let db = Self.dbManager.ncDatabase() + debugPrint(db) + + let remoteInterface = MockRemoteInterface() + XCTAssert(remoteInterface.capabilities.contains(##""undelete": true,"##)) + let metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test", account: Self.account) + Self.dbManager.addItemMetadata(metadata) + let item = await Item.storedItem( + identifier: .init(metadata.ocId), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + XCTAssertEqual(item?.capabilities.contains(.allowsTrashing), true) } func testItemShared() { From a6145d33a4710cf16b8c9771f427262786110b79 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 27 May 2025 17:10:13 +0800 Subject: [PATCH 08/15] Prevent concurrent capabilities checks for a given account, instead wait for ongoing requests to finish before proceeding Signed-off-by: Claudio Cambra --- .../NextcloudKit+RemoteInterface.swift | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift b/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift index 326743a9..d9617387 100644 --- a/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift +++ b/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift @@ -19,15 +19,41 @@ actor RetrievedCapabilitiesActor { let instance = RetrievedCapabilitiesActor() return instance }() + var ongoingFetches: Set = [] var data: [String: (capabilities: Capabilities, retrievedAt: Date)] = [:] + private var ongoingFetchContinuations: [String: [CheckedContinuation]] = [:] + func setCapabilities( forAccount account: String, capabilities: Capabilities, retrievedAt: Date = Date() - ) async { + ) { self.data[account] = (capabilities: capabilities, retrievedAt: retrievedAt) } + + func setOngoingFetch(forAccount account: String, ongoing: Bool) { + if ongoing { + ongoingFetches.insert(account) + } else { + ongoingFetches.remove(account) + // If there are any continuations waiting for this account, resume them. + if let continuations = ongoingFetchContinuations.removeValue(forKey: account) { + continuations.forEach { $0.resume() } + } + } + } + + func awaitFetchCompletion(forAccount account: String) async { + guard ongoingFetches.contains(account) else { return } + + // If a fetch is ongoing, create a continuation and store it. + await withCheckedContinuation { continuation in + var existingContinuations = ongoingFetchContinuations[account, default: []] + existingContinuations.append(continuation) + ongoingFetchContinuations[account] = existingContinuations + } + } } extension NextcloudKit: RemoteInterface { @@ -398,6 +424,10 @@ extension NextcloudKit: RemoteInterface { options: NKRequestOptions = .init(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) { + let ncKitAccount = account.ncKitAccount + await RetrievedCapabilitiesActor.shared.setOngoingFetch( + forAccount: ncKitAccount, ongoing: true + ) let result = await withCheckedContinuation { continuation in getCapabilities(account: account.ncKitAccount, options: options, taskHandler: taskHandler) { account, data, error in let capabilities: Capabilities? = { @@ -407,6 +437,9 @@ extension NextcloudKit: RemoteInterface { continuation.resume(returning: (account, capabilities, data?.data, error)) } } + await RetrievedCapabilitiesActor.shared.setOngoingFetch( + forAccount: ncKitAccount, ongoing: false + ) if let capabilities = result.1 { await RetrievedCapabilitiesActor.shared.setCapabilities( forAccount: account.ncKitAccount, capabilities: capabilities @@ -421,6 +454,7 @@ extension NextcloudKit: RemoteInterface { taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) { let ncKitAccount = account.ncKitAccount + await RetrievedCapabilitiesActor.shared.awaitFetchCompletion(forAccount: ncKitAccount) guard let lastRetrieval = await RetrievedCapabilitiesActor.shared.data[ncKitAccount], lastRetrieval.retrievedAt.timeIntervalSince(Date()) > -CapabilitiesFetchInterval else { From c3f886dbc71c6c20b2eb1e2bf8951e1357ceaa98 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 28 May 2025 11:36:47 +0800 Subject: [PATCH 09/15] Standardise current capabilities implementation Signed-off-by: Claudio Cambra --- .../NextcloudKit+RemoteInterface.swift | 61 ---------------- .../Interface/RemoteInterface.swift | 71 ++++++++++++++++--- Tests/Interface/MockRemoteInterface.swift | 8 --- 3 files changed, 62 insertions(+), 78 deletions(-) diff --git a/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift b/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift index d9617387..f379a780 100644 --- a/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift +++ b/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift @@ -12,50 +12,6 @@ import NextcloudCapabilitiesKit import NextcloudKit import OSLog -fileprivate let CapabilitiesFetchInterval: TimeInterval = 30 * 60 // 30mins - -actor RetrievedCapabilitiesActor { - static let shared: RetrievedCapabilitiesActor = { - let instance = RetrievedCapabilitiesActor() - return instance - }() - var ongoingFetches: Set = [] - var data: [String: (capabilities: Capabilities, retrievedAt: Date)] = [:] - - private var ongoingFetchContinuations: [String: [CheckedContinuation]] = [:] - - func setCapabilities( - forAccount account: String, - capabilities: Capabilities, - retrievedAt: Date = Date() - ) { - self.data[account] = (capabilities: capabilities, retrievedAt: retrievedAt) - } - - func setOngoingFetch(forAccount account: String, ongoing: Bool) { - if ongoing { - ongoingFetches.insert(account) - } else { - ongoingFetches.remove(account) - // If there are any continuations waiting for this account, resume them. - if let continuations = ongoingFetchContinuations.removeValue(forKey: account) { - continuations.forEach { $0.resume() } - } - } - } - - func awaitFetchCompletion(forAccount account: String) async { - guard ongoingFetches.contains(account) else { return } - - // If a fetch is ongoing, create a continuation and store it. - await withCheckedContinuation { continuation in - var existingContinuations = ongoingFetchContinuations[account, default: []] - existingContinuations.append(continuation) - ongoingFetchContinuations[account] = existingContinuations - } - } -} - extension NextcloudKit: RemoteInterface { public func setDelegate(_ delegate: any NextcloudKitDelegate) { @@ -448,23 +404,6 @@ extension NextcloudKit: RemoteInterface { return result } - public func currentCapabilities( - account: Account, - options: NKRequestOptions = .init(), - taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } - ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) { - let ncKitAccount = account.ncKitAccount - await RetrievedCapabilitiesActor.shared.awaitFetchCompletion(forAccount: ncKitAccount) - guard let lastRetrieval = await RetrievedCapabilitiesActor.shared.data[ncKitAccount], - lastRetrieval.retrievedAt.timeIntervalSince(Date()) > -CapabilitiesFetchInterval - else { - return await fetchCapabilities( - account: account, options: options, taskHandler: taskHandler - ) - } - return (account.ncKitAccount, lastRetrieval.capabilities, nil, .success) - } - public func fetchUserProfile( account: Account, options: NKRequestOptions = .init(), diff --git a/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift b/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift index bd821b4a..353a1903 100644 --- a/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift +++ b/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift @@ -12,6 +12,8 @@ import NextcloudCapabilitiesKit import NextcloudKit import OSLog +fileprivate let CapabilitiesFetchInterval: TimeInterval = 30 * 60 // 30mins + public enum EnumerateDepth: String { case target = "0" case targetAndDirectChildren = "1" @@ -22,6 +24,48 @@ public enum AuthenticationAttemptResultState: Int { case authenticationError, connectionError, success } +actor RetrievedCapabilitiesActor { + static let shared: RetrievedCapabilitiesActor = { + let instance = RetrievedCapabilitiesActor() + return instance + }() + var ongoingFetches: Set = [] + var data: [String: (capabilities: Capabilities, retrievedAt: Date)] = [:] + + private var ongoingFetchContinuations: [String: [CheckedContinuation]] = [:] + + func setCapabilities( + forAccount account: String, + capabilities: Capabilities, + retrievedAt: Date = Date() + ) { + self.data[account] = (capabilities: capabilities, retrievedAt: retrievedAt) + } + + func setOngoingFetch(forAccount account: String, ongoing: Bool) { + if ongoing { + ongoingFetches.insert(account) + } else { + ongoingFetches.remove(account) + // If there are any continuations waiting for this account, resume them. + if let continuations = ongoingFetchContinuations.removeValue(forKey: account) { + continuations.forEach { $0.resume() } + } + } + } + + func awaitFetchCompletion(forAccount account: String) async { + guard ongoingFetches.contains(account) else { return } + + // If a fetch is ongoing, create a continuation and store it. + await withCheckedContinuation { continuation in + var existingContinuations = ongoingFetchContinuations[account, default: []] + existingContinuations.append(continuation) + ongoingFetchContinuations[account] = existingContinuations + } + } +} + public protocol RemoteInterface { func setDelegate(_ delegate: NextcloudKitDelegate) @@ -158,14 +202,6 @@ public protocol RemoteInterface { taskHandler: @escaping (_ task: URLSessionTask) -> Void ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) - // This method should result in fetches only after a certain period of time. - // Alternatively, it should only fetch when capabilities are guaranteed to have changed. - func currentCapabilities( - account: Account, - options: NKRequestOptions, - taskHandler: @escaping (_ task: URLSessionTask) -> Void - ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) - func fetchUserProfile( account: Account, options: NKRequestOptions, @@ -185,13 +221,30 @@ public extension RemoteInterface { Logger(subsystem: Logger.subsystem, category: "RemoteInterface") } + func currentCapabilities( + account: Account, + options: NKRequestOptions = .init(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) { + let ncKitAccount = account.ncKitAccount + await RetrievedCapabilitiesActor.shared.awaitFetchCompletion(forAccount: ncKitAccount) + guard let lastRetrieval = await RetrievedCapabilitiesActor.shared.data[ncKitAccount], + lastRetrieval.retrievedAt.timeIntervalSince(Date()) > -CapabilitiesFetchInterval + else { + return await fetchCapabilities( + account: account, options: options, taskHandler: taskHandler + ) + } + return (account.ncKitAccount, lastRetrieval.capabilities, nil, .success) + } + func supportsTrash( account: Account, options: NKRequestOptions = .init(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } ) async -> Bool { var remoteSupportsTrash = false - let (_, capabilities, _, error) = await currentCapabilities( + let (_, capabilities, _, _) = await currentCapabilities( account: account, options: .init(), taskHandler: { _ in } ) if let filesCapabilities = capabilities?.files { diff --git a/Tests/Interface/MockRemoteInterface.swift b/Tests/Interface/MockRemoteInterface.swift index beb9e283..9a5793bc 100644 --- a/Tests/Interface/MockRemoteInterface.swift +++ b/Tests/Interface/MockRemoteInterface.swift @@ -1142,14 +1142,6 @@ public class MockRemoteInterface: RemoteInterface { return (account.ncKitAccount, directMockCapabilities(), capsData, .success) } - public func currentCapabilities( - account: Account, - options: NKRequestOptions = .init(), - taskHandler: @escaping (URLSessionTask) -> Void = { _ in } - ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) { - return await fetchCapabilities(account: account, options: options, taskHandler: taskHandler) - } - public func directMockCapabilities() -> Capabilities? { let capsData = capabilities.data(using: .utf8) return Capabilities(data: capsData ?? Data()) From 38886abd54e8ba506455bb5391bdae6fb78ea4a7 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 28 May 2025 11:42:58 +0800 Subject: [PATCH 10/15] Move capabilities actor into its own file Signed-off-by: Claudio Cambra --- .../Interface/RemoteInterface.swift | 43 --------------- .../RetrievedCapabilitiesActor.swift | 53 +++++++++++++++++++ 2 files changed, 53 insertions(+), 43 deletions(-) create mode 100644 Sources/NextcloudFileProviderKit/Utilities/RetrievedCapabilitiesActor.swift diff --git a/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift b/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift index 353a1903..2491193a 100644 --- a/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift +++ b/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift @@ -12,7 +12,6 @@ import NextcloudCapabilitiesKit import NextcloudKit import OSLog -fileprivate let CapabilitiesFetchInterval: TimeInterval = 30 * 60 // 30mins public enum EnumerateDepth: String { case target = "0" @@ -24,48 +23,6 @@ public enum AuthenticationAttemptResultState: Int { case authenticationError, connectionError, success } -actor RetrievedCapabilitiesActor { - static let shared: RetrievedCapabilitiesActor = { - let instance = RetrievedCapabilitiesActor() - return instance - }() - var ongoingFetches: Set = [] - var data: [String: (capabilities: Capabilities, retrievedAt: Date)] = [:] - - private var ongoingFetchContinuations: [String: [CheckedContinuation]] = [:] - - func setCapabilities( - forAccount account: String, - capabilities: Capabilities, - retrievedAt: Date = Date() - ) { - self.data[account] = (capabilities: capabilities, retrievedAt: retrievedAt) - } - - func setOngoingFetch(forAccount account: String, ongoing: Bool) { - if ongoing { - ongoingFetches.insert(account) - } else { - ongoingFetches.remove(account) - // If there are any continuations waiting for this account, resume them. - if let continuations = ongoingFetchContinuations.removeValue(forKey: account) { - continuations.forEach { $0.resume() } - } - } - } - - func awaitFetchCompletion(forAccount account: String) async { - guard ongoingFetches.contains(account) else { return } - - // If a fetch is ongoing, create a continuation and store it. - await withCheckedContinuation { continuation in - var existingContinuations = ongoingFetchContinuations[account, default: []] - existingContinuations.append(continuation) - ongoingFetchContinuations[account] = existingContinuations - } - } -} - public protocol RemoteInterface { func setDelegate(_ delegate: NextcloudKitDelegate) diff --git a/Sources/NextcloudFileProviderKit/Utilities/RetrievedCapabilitiesActor.swift b/Sources/NextcloudFileProviderKit/Utilities/RetrievedCapabilitiesActor.swift new file mode 100644 index 00000000..2aaa8f94 --- /dev/null +++ b/Sources/NextcloudFileProviderKit/Utilities/RetrievedCapabilitiesActor.swift @@ -0,0 +1,53 @@ +// +// RetrievedCapabilitiesActor.swift +// NextcloudFileProviderKit +// +// Created by Claudio Cambra on 28/5/25. +// + +import Foundation +import NextcloudCapabilitiesKit + +let CapabilitiesFetchInterval: TimeInterval = 30 * 60 // 30mins + +actor RetrievedCapabilitiesActor { + static let shared: RetrievedCapabilitiesActor = { + let instance = RetrievedCapabilitiesActor() + return instance + }() + var ongoingFetches: Set = [] + var data: [String: (capabilities: Capabilities, retrievedAt: Date)] = [:] + + private var ongoingFetchContinuations: [String: [CheckedContinuation]] = [:] + + func setCapabilities( + forAccount account: String, + capabilities: Capabilities, + retrievedAt: Date = Date() + ) { + self.data[account] = (capabilities: capabilities, retrievedAt: retrievedAt) + } + + func setOngoingFetch(forAccount account: String, ongoing: Bool) { + if ongoing { + ongoingFetches.insert(account) + } else { + ongoingFetches.remove(account) + // If there are any continuations waiting for this account, resume them. + if let continuations = ongoingFetchContinuations.removeValue(forKey: account) { + continuations.forEach { $0.resume() } + } + } + } + + func awaitFetchCompletion(forAccount account: String) async { + guard ongoingFetches.contains(account) else { return } + + // If a fetch is ongoing, create a continuation and store it. + await withCheckedContinuation { continuation in + var existingContinuations = ongoingFetchContinuations[account, default: []] + existingContinuations.append(continuation) + ongoingFetchContinuations[account] = existingContinuations + } + } +} From 9b9a7a190a46660193e79952ed65feab9b94c0e5 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 28 May 2025 13:06:04 +0800 Subject: [PATCH 11/15] Add tests for RetrievedCapabilitiesActor Signed-off-by: Claudio Cambra --- Tests/Interface/MockRemoteInterface.swift | 2 +- .../RetrievedCapabilitiesActorTests.swift | 185 ++++++++++++++++++ 2 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 Tests/NextcloudFileProviderKitTests/RetrievedCapabilitiesActorTests.swift diff --git a/Tests/Interface/MockRemoteInterface.swift b/Tests/Interface/MockRemoteInterface.swift index 9a5793bc..ca848d79 100644 --- a/Tests/Interface/MockRemoteInterface.swift +++ b/Tests/Interface/MockRemoteInterface.swift @@ -11,7 +11,7 @@ import NextcloudCapabilitiesKit import NextcloudFileProviderKit import NextcloudKit -fileprivate let mockCapabilities = ##""" +let mockCapabilities = ##""" { "ocs": { "meta": { diff --git a/Tests/NextcloudFileProviderKitTests/RetrievedCapabilitiesActorTests.swift b/Tests/NextcloudFileProviderKitTests/RetrievedCapabilitiesActorTests.swift new file mode 100644 index 00000000..6dec63a3 --- /dev/null +++ b/Tests/NextcloudFileProviderKitTests/RetrievedCapabilitiesActorTests.swift @@ -0,0 +1,185 @@ +// +// RetrievedCapabilitiesActorTests.swift +// NextcloudFileProviderKit +// +// Created by Claudio Cambra on 28/5/25. +// + +import Foundation +import NextcloudCapabilitiesKit +import Testing +@testable import NextcloudFileProviderKit +@testable import TestInterface + +@Suite("RetrievedCapabilitiesActor tests") +struct RetrievedCapabilitiesActorTests { + + let account1 = "acc1" + let account2 = "acc2" + + @Test func setCapabilitiesCompletes() async { + let actor = RetrievedCapabilitiesActor() // New instance for the test + let capsData = mockCapabilities.data(using: .utf8)! + let caps = Capabilities(data: capsData)! + let specificDate = Date(timeIntervalSince1970: 1234567890) + + // We call the public API. + await actor.setCapabilities(forAccount: account1, capabilities: caps, retrievedAt: specificDate) + let setCaps = await actor.data[account1] + + #expect(setCaps?.retrievedAt == specificDate) + #expect(setCaps?.capabilities != nil) + } + + @Test func setOngoingFetchTrueCausesSuspension() async throws { + let actor = RetrievedCapabilitiesActor() + var awaiterDidProceed = false + + // 1. Mark fetch as ongoing + await actor.setOngoingFetch(forAccount: account1, ongoing: true) + + // 2. Attempt to await in a separate task + let awaitingTask = Task { + await actor.awaitFetchCompletion(forAccount: account1) + awaiterDidProceed = true // This should only become true after resumption + } + + // 3. Give the awaitingTask a moment to potentially run and suspend + try await Task.sleep(for: .milliseconds(100)) + #expect(!awaiterDidProceed, "`awaitFetchCompletion` should suspend if fetch is ongoing.") + + // 4. Clean up: complete the fetch to allow the task to finish + await actor.setOngoingFetch(forAccount: account1, ongoing: false) + await awaitingTask.value // Ensure the task fully completes + #expect(awaiterDidProceed, "Awaiter should proceed after fetch is no longer ongoing.") + } + + @Test func setOngoingFetchFalseResumesAwaiter() async throws { + let actor = RetrievedCapabilitiesActor() + var awaiterCompleted = false + + // 1. Mark fetch as ongoing and start an awaiter + await actor.setOngoingFetch(forAccount: account1, ongoing: true) + let awaitingTask = Task { + await actor.awaitFetchCompletion(forAccount: account1) + awaiterCompleted = true + } + + // 2. Ensure it's waiting + try await Task.sleep(for: .milliseconds(100)) + #expect(awaiterCompleted == false, "Awaiter should be suspended initially.") + + // 3. Mark fetch as not ongoing, which should resume the awaiter + await actor.setOngoingFetch(forAccount: account1, ongoing: false) + + // 4. Await the task's completion and check the flag + await awaitingTask.value + #expect(awaiterCompleted, "Awaiter should complete after `setOngoingFetch(false)`.") + } + + @Test func awaitFetchCompletionReturnsImmediately() async throws { + let actor = RetrievedCapabilitiesActor() + var didAwaiterCompleteImmediately = false + + let task = Task { + await actor.awaitFetchCompletion(forAccount: account1) + didAwaiterCompleteImmediately = true + } + await task.value + + #expect( + didAwaiterCompleteImmediately, + "`awaitFetchCompletion` should let the task complete quickly if no fetch is ongoing." + ) + } + + @Test func awaitFetchCompletion_suspendsAndResumes_behavioral() async throws { + let actor = RetrievedCapabilitiesActor() + var didAwaiterComplete = false + + // 1. Mark fetch as ongoing + await actor.setOngoingFetch(forAccount: account1, ongoing: true) + + // 2. Start task that awaits + let awaitingTask = Task { + await actor.awaitFetchCompletion(forAccount: account1) + didAwaiterComplete = true + } + + // 3. Check for suspension (indirectly) + try await Task.sleep(for: .milliseconds(100)) + #expect(!didAwaiterComplete, "Awaiter should be suspended while fetch is ongoing.") + + // 4. Mark fetch as completed + await actor.setOngoingFetch(forAccount: account1, ongoing: false) + + // 5. Awaiter should complete + await awaitingTask.value + #expect(didAwaiterComplete, "Awaiter should complete after fetch is no longer ongoing.") + } + + @Test func awaitFetchCompletion_multipleAwaiters_behavioral() async throws { + let actor = RetrievedCapabilitiesActor() + var awaiter1Complete = false + var awaiter2Complete = false + + await actor.setOngoingFetch(forAccount: account1, ongoing: true) + + let task1 = Task { + await actor.awaitFetchCompletion(forAccount: account1) + awaiter1Complete = true + } + let task2 = Task { + await actor.awaitFetchCompletion(forAccount: account1) + awaiter2Complete = true + } + + try await Task.sleep(for: .milliseconds(100)) + #expect(!awaiter1Complete && !awaiter2Complete, "Both awaiters should be suspended.") + + await actor.setOngoingFetch(forAccount: account1, ongoing: false) + + await task1.value + await task2.value + #expect( + awaiter1Complete && awaiter2Complete, + "Both awaiters should complete after fetch is no longer ongoing." + ) + } + + @Test func setOngoingFetch_false_isolatesAccountResumption_behavioral() async throws { + let actor = RetrievedCapabilitiesActor() + var acc1AwaiterDone = false + var acc2AwaiterDone = false + + // Start fetches for both accounts + await actor.setOngoingFetch(forAccount: account1, ongoing: true) + await actor.setOngoingFetch(forAccount: account2, ongoing: true) + + // Setup awaiters + let taskAcc1 = Task { + await actor.awaitFetchCompletion(forAccount: account1) + acc1AwaiterDone = true + } + let taskAcc2 = Task { + await actor.awaitFetchCompletion(forAccount: account2) + acc2AwaiterDone = true + } + + try await Task.sleep(for: .milliseconds(100)) // Allow tasks to suspend + #expect(!acc1AwaiterDone && !acc2AwaiterDone, "Both awaiters initially suspended.") + + // Complete fetch for account1 ONLY + await actor.setOngoingFetch(forAccount: account1, ongoing: false) + await taskAcc1.value + + #expect(acc1AwaiterDone, "Awaiter for account1 should complete.") + #expect(!acc2AwaiterDone, "Awaiter for account2 should still be suspended.") + + // Complete fetch for account2 + await actor.setOngoingFetch(forAccount: account2, ongoing: false) + await taskAcc2.value // Wait for acc2's awaiter to complete + + #expect(acc2AwaiterDone, "Awaiter for account2 should now complete.") + } +} From 88f221470c696c7520affa49079e2863b5b197cd Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 28 May 2025 16:16:02 +0800 Subject: [PATCH 12/15] Upgrade to NextcloudCapabilitiesKit 2.3.0 Signed-off-by: Claudio Cambra --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 74aa0969..1a94aa5d 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/claucambra/NextcloudCapabilitiesKit.git", - .upToNextMajor(from: "2.1.2") + .upToNextMajor(from: "2.3.0") ), .package(url: "https://github.com/nextcloud/NextcloudKit", from: "5.0.4"), .package(url: "https://github.com/realm/realm-swift.git", exact: "20.0.1"), From 432225c5a92495a3e7a4f8c743a0c67f52328973 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 28 May 2025 16:16:15 +0800 Subject: [PATCH 13/15] Add reset method to retrieved capabilities actor Signed-off-by: Claudio Cambra --- .../Utilities/RetrievedCapabilitiesActor.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/NextcloudFileProviderKit/Utilities/RetrievedCapabilitiesActor.swift b/Sources/NextcloudFileProviderKit/Utilities/RetrievedCapabilitiesActor.swift index 2aaa8f94..5de18d0e 100644 --- a/Sources/NextcloudFileProviderKit/Utilities/RetrievedCapabilitiesActor.swift +++ b/Sources/NextcloudFileProviderKit/Utilities/RetrievedCapabilitiesActor.swift @@ -50,4 +50,10 @@ actor RetrievedCapabilitiesActor { ongoingFetchContinuations[account] = existingContinuations } } + + func reset() { + ongoingFetches = [] + ongoingFetchContinuations = [:] + data = [:] + } } From 961aa78fcf810b7b152d83414d68e07a883fc57a Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 28 May 2025 16:16:36 +0800 Subject: [PATCH 14/15] Add test suite for the remote interface extension methods Signed-off-by: Claudio Cambra --- .../RemoteInterfaceTests.swift | 486 ++++++++++++++++++ 1 file changed, 486 insertions(+) create mode 100644 Tests/NextcloudFileProviderKitTests/RemoteInterfaceTests.swift diff --git a/Tests/NextcloudFileProviderKitTests/RemoteInterfaceTests.swift b/Tests/NextcloudFileProviderKitTests/RemoteInterfaceTests.swift new file mode 100644 index 00000000..dcd67511 --- /dev/null +++ b/Tests/NextcloudFileProviderKitTests/RemoteInterfaceTests.swift @@ -0,0 +1,486 @@ +// +// RemoteInterfaceTests.swift +// +// +// Created by Claudio Cambra on 27/5/25. +// + +import Alamofire +import Foundation +import NextcloudCapabilitiesKit +import NextcloudKit +import Testing +@testable import TestInterface +@testable import NextcloudFileProviderKit + +fileprivate struct TestableRemoteInterface: RemoteInterface { + func setDelegate(_ delegate: any NextcloudKitDelegate) {} + + func createFolder( + remotePath: String, + account: Account, + options: NKRequestOptions, + taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, ocId: String?, date: NSDate?, error: NKError) { + ("", nil, nil, .invalidResponseError) + } + + func upload( + remotePath: String, + localPath: String, + creationDate: Date?, + modificationDate: Date?, + account: Account, + options: NKRequestOptions, + requestHandler: @escaping (UploadRequest) -> Void, + taskHandler: @escaping (URLSessionTask) -> Void, + progressHandler: @escaping (Progress) -> Void + ) async -> ( + account: String, + ocId: String?, + etag: String?, + date: NSDate?, + size: Int64, + response: HTTPURLResponse?, + afError: AFError?, + remoteError: NKError + ) { ("", nil, nil, nil, 0, nil, nil, .invalidResponseError) } + + func chunkedUpload( + localPath: String, + remotePath: String, + remoteChunkStoreFolderName: String, + chunkSize: Int, + remainingChunks: [RemoteFileChunk], + creationDate: Date?, + modificationDate: Date?, + account: Account, + options: NKRequestOptions, + currentNumChunksUpdateHandler: @escaping (Int) -> Void, + chunkCounter: @escaping (Int) -> Void, + chunkUploadStartHandler: @escaping ([RemoteFileChunk]) -> Void, + requestHandler: @escaping (UploadRequest) -> Void, + taskHandler: @escaping (URLSessionTask) -> Void, + progressHandler: @escaping (Progress) -> Void, + chunkUploadCompleteHandler: @escaping (RemoteFileChunk) -> Void + ) async -> ( + account: String, + fileChunks: [RemoteFileChunk]?, + file: NKFile?, + afError: AFError?, + remoteError: NKError + ) { ("", nil, nil, nil, .invalidResponseError) } + + func move( + remotePathSource: String, + remotePathDestination: String, + overwrite: Bool, + account: Account, + options: NKRequestOptions, + taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, data: Data?, error: NKError) { ("", nil, .invalidResponseError) } + + func download( + remotePath: String, + localPath: String, + account: Account, + options: NKRequestOptions, + requestHandler: @escaping (DownloadRequest) -> Void, + taskHandler: @escaping (URLSessionTask) -> Void, + progressHandler: @escaping (Progress) -> Void + ) async -> ( + account: String, + etag: String?, + date: NSDate?, + length: Int64, + response: HTTPURLResponse?, + afError: AFError?, + remoteError: NKError + ) { ("", nil, nil, 0, nil, nil, .invalidResponseError) } + + func enumerate( + remotePath: String, + depth: EnumerateDepth, + showHiddenFiles: Bool, + includeHiddenFiles: [String], + requestBody: Data?, + account: Account, + options: NKRequestOptions, + taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, files: [NKFile], data: Data?, error: NKError) { + ("", [], nil, .invalidResponseError) + } + + func delete( + remotePath: String, + account: Account, + options: NKRequestOptions, + taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, response: HTTPURLResponse?, error: NKError) { + ("", nil, .invalidResponseError) + } + + func setLockStateForFile( + remotePath: String, + lock: Bool, + account: Account, + options: NKRequestOptions, + taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, response: HTTPURLResponse?, error: NKError) { + ("", nil, .invalidResponseError) + } + + func trashedItems( + account: Account, options: NKRequestOptions, taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, trashedItems: [NKTrash], data: Data?, error: NKError) { + ("", [], nil, .invalidResponseError) + } + + func restoreFromTrash( + filename: String, + account: Account, + options: NKRequestOptions, + taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, data: Data?, error: NKError) { ("", nil, .invalidResponseError) } + + func downloadThumbnail( + url: URL, + account: Account, + options: NKRequestOptions, + taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, data: Data?, error: NKError) { ("", nil, .invalidResponseError) } + + func fetchUserProfile( + account: Account, options: NKRequestOptions, taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, userProfile: NKUserProfile?, data: Data?, error: NKError) { + ("", nil, nil, .invalidResponseError) + } + + func tryAuthenticationAttempt( + account: Account, options: NKRequestOptions, taskHandler: @escaping (URLSessionTask) -> Void + ) async -> AuthenticationAttemptResultState { .connectionError } + + typealias FetchResult = (account: String, capabilities: Capabilities?, data: Data?, error: NKError) + + var fetchCapabilitiesHandler: + ((Account, NKRequestOptions, @escaping (URLSessionTask) -> Void) async -> FetchResult)? + + func fetchCapabilities( + account: Account, + options: NKRequestOptions = .init(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> FetchResult { + let ncKitAccount = account.ncKitAccount + await RetrievedCapabilitiesActor.shared.setOngoingFetch( + forAccount: ncKitAccount, ongoing: true + ) + var response: FetchResult + if let handler = fetchCapabilitiesHandler { + response = await handler(account, options, taskHandler) + if let caps = response.capabilities { + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: ncKitAccount, capabilities: caps, retrievedAt: Date() + ) + } + } else { + print("Error: fetchCapabilitiesHandler not set in TestableRemoteInterface") + response = (account.ncKitAccount, nil, nil, .invalidResponseError) + } + await RetrievedCapabilitiesActor.shared.setOngoingFetch( + forAccount: account.ncKitAccount, ongoing: false + ) + return response + } +} + +@Suite("RemoteInterface Extension Tests", .serialized) +struct RemoteInterfaceExtensionTests { + + let testAccount = Account(user: "a1", id: "1", serverUrl: "example.com", password: "pass") + let otherAccount = Account(user: "a2", id: "2", serverUrl: "example.com", password: "word") + + func capabilitiesFromMockJSON(jsonString: String = mockCapabilities) -> (Capabilities, Data) { + let data = jsonString.data(using: .utf8)! + let caps = Capabilities(data: data)! + return (caps, data) + } + + @Test func currentCapabilitiesReturnsFreshCache() async { + await RetrievedCapabilitiesActor.shared.reset() + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { _, _, _ in + Issue.record("fetchCapabilities should NOT be called when cache is fresh.") + return (self.testAccount.ncKitAccount, nil, nil, .invalidResponseError) + } + + let (freshCaps, _) = capabilitiesFromMockJSON() + let freshDate = Date() // Now + + // Setup: Put fresh data into the shared actor + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: testAccount.ncKitAccount, + capabilities: freshCaps, + retrievedAt: freshDate + ) + + let result = await remoteInterface.currentCapabilities(account: testAccount) + + #expect(result.error == .success) + #expect(result.capabilities == freshCaps) + #expect(result.data == nil, "Data should be nil as no fetch occurred") + #expect(result.account == testAccount.ncKitAccount) + } + + @Test func currentCapabilitiesFetchesOnNoCache() async throws { + await RetrievedCapabilitiesActor.shared.reset() + + let (fetchedCaps, fetchedData) = capabilitiesFromMockJSON() + var fetcherCalled = false + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + fetcherCalled = true + #expect(acc.ncKitAccount == self.testAccount.ncKitAccount) + return (acc.ncKitAccount, fetchedCaps, fetchedData, .success) + } + + let result = await remoteInterface.currentCapabilities(account: testAccount) + + #expect(fetcherCalled, "fetchCapabilities should be called when cache is empty.") + #expect(result.error == .success) + #expect(result.capabilities == fetchedCaps) + #expect(result.data == fetchedData) + + let actorCache = await RetrievedCapabilitiesActor.shared.data + #expect(actorCache[testAccount.ncKitAccount]?.capabilities == fetchedCaps) + } + + @Test func currentCapabilitiesFetchesOnStaleCache() async throws { + await RetrievedCapabilitiesActor.shared.reset() + + let (staleCaps, _) = capabilitiesFromMockJSON(jsonString: """ + { + "ocs": { + "meta": { + "status": "ok", + "statuscode": 100, + "message": "OK" + }, + "data": { + "capabilities": { + "files": { + "undelete": false + } + } + } + } + } + """) // Different caps + let staleDate = Date(timeIntervalSinceNow: -(CapabilitiesFetchInterval + 300)) // Definitely stale + + // Setup: Put stale data into the actor + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: testAccount.ncKitAccount, + capabilities: staleCaps, + retrievedAt: staleDate + ) + + let (newCaps, newData) = capabilitiesFromMockJSON() // Fresh data to be fetched + var fetcherCalled = false + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + fetcherCalled = true + return (acc.ncKitAccount, newCaps, newData, .success) + } + + let result = await remoteInterface.currentCapabilities(account: testAccount) + + #expect(fetcherCalled, "fetchCapabilities should be called for stale cache.") + #expect(result.error == .success) + #expect(result.capabilities == newCaps, "Should return newly fetched capabilities.") + #expect(result.data == newData) + + let actorCache = await RetrievedCapabilitiesActor.shared.data + #expect(actorCache[testAccount.ncKitAccount]?.capabilities == newCaps) + #expect((actorCache[testAccount.ncKitAccount]?.retrievedAt ?? .distantPast) > staleDate) + } + + @Test func currentCapabilitiesAwaitsAndUsesCache() async throws { + await RetrievedCapabilitiesActor.shared.reset() + + let (cachedCaps, cachedData) = capabilitiesFromMockJSON() + var fetcherCalledCount = 0 + + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + fetcherCalledCount += 1 + // This fetcher should not be called if cache is fresh after await. + return (acc.ncKitAccount, cachedCaps, cachedData, .success) + } + + // 1. Simulate an external process starting a fetch for testAccount + await RetrievedCapabilitiesActor.shared.setOngoingFetch( + forAccount: testAccount.ncKitAccount, ongoing: true + ) + + var currentCapabilitiesReturned = false + let currentCapabilitiesTask = Task { + // 2. This call to currentCapabilities should await the ongoing fetch. + let result = await remoteInterface.currentCapabilities(account: testAccount) + currentCapabilitiesReturned = true + // Assertions on the result will be done after the task. + #expect(result.capabilities == cachedCaps) + #expect(result.error == .success) + } + + // 3. Give currentCapabilitiesTask a moment to hit the await. + try await Task.sleep(for: .milliseconds(100)) + #expect(currentCapabilitiesReturned == false, "currentCapabilities should be awaiting.") + + // 4. Now, the "external" fetch completes and populates the cache. + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: testAccount.ncKitAccount, + capabilities: cachedCaps, + retrievedAt: Date() // Fresh date + ) + await RetrievedCapabilitiesActor.shared.setOngoingFetch( + forAccount: testAccount.ncKitAccount, ongoing: false + ) + + // 5. currentCapabilitiesTask should now complete. + await currentCapabilitiesTask.value + #expect(currentCapabilitiesReturned == true) + + // Check if fetchCapabilities was called. + // If the logic is: await -> check cache -> fetch if needed. + // And we made cache fresh before await unblocked, it should NOT call fetch. + #expect(fetcherCalledCount == 0, "fetchCapabilities should not have been called if cache was fresh after await.") + } + + @Test func supportsTrashTrue() async throws { + await RetrievedCapabilitiesActor.shared.reset() // Reset shared actor + + // JSON where files.undelete is true (default mockCapabilitiesJSON) + let (capsWithTrash, dataWithTrash) = capabilitiesFromMockJSON() + #expect(capsWithTrash.files?.undelete == true) + + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + return (acc.ncKitAccount, capsWithTrash, dataWithTrash, .success) + } + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: testAccount.ncKitAccount, + capabilities: capsWithTrash, // any capability + retrievedAt: Date(timeIntervalSinceNow: -(CapabilitiesFetchInterval + 100)) // Stale + ) + + let result = await remoteInterface.supportsTrash(account: testAccount) + #expect(result == true) + } + + @Test func supportsTrashFalse() async throws { + await RetrievedCapabilitiesActor.shared.reset() + let jsonNoUndelete = """ + { + "ocs": { + "meta": { + "status": "ok", + "statuscode": 100, + "message": "OK" + }, + "data": { + "capabilities": { + "files": { + "undelete": false + } + } + } + } + } + """ + let (capsNoTrash, dataNoTrash) = capabilitiesFromMockJSON(jsonString: jsonNoUndelete) + #expect(capsNoTrash.files?.undelete == false) + + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: acc.ncKitAccount, capabilities: capsNoTrash, retrievedAt: Date() + ) + return (acc.ncKitAccount, capsNoTrash, dataNoTrash, .success) + } + await RetrievedCapabilitiesActor.shared.setCapabilities( // Stale entry + forAccount: testAccount.ncKitAccount, + capabilities: capsNoTrash, + retrievedAt: Date(timeIntervalSinceNow: -(CapabilitiesFetchInterval + 100)) + ) + + let result = await remoteInterface.supportsTrash(account: testAccount) + #expect(result == false) + } + + @Test func supportsTrashNilCapabilities() async throws { + await RetrievedCapabilitiesActor.shared.reset() + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + return (acc.ncKitAccount, nil, nil, .invalidResponseError) + } + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: testAccount.ncKitAccount, + capabilities: capabilitiesFromMockJSON().0, + retrievedAt: Date(timeIntervalSinceNow: -(CapabilitiesFetchInterval + 100)) + ) + + let result = await remoteInterface.supportsTrash(account: testAccount) + #expect(!result) + } + + @Test func supportsTrashNilFilesSection() async throws { + await RetrievedCapabilitiesActor.shared.reset() + let jsonNoFilesSection = """ + { + "ocs": { + "meta": { + "status": "ok", + "statuscode": 100, + "message": "OK" + }, + "data": { + "capabilities": { + "core": { + "pollinterval": 60 + } + } + } + } + } + """ + // This JSON will result in `Capabilities.files` being nil + let (capsNoFiles, dataNoFiles) = capabilitiesFromMockJSON(jsonString: jsonNoFilesSection) + #expect(capsNoFiles.files?.undelete != true) // Check our parsing logic + + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + (acc.ncKitAccount, capsNoFiles, dataNoFiles, .success) + } + await RetrievedCapabilitiesActor.shared.setCapabilities( // Stale entry + forAccount: testAccount.ncKitAccount, + capabilities: capsNoFiles, + retrievedAt: Date(timeIntervalSinceNow: -(CapabilitiesFetchInterval + 100)) + ) + + let result = await remoteInterface.supportsTrash(account: testAccount) + #expect(!result) + } + + @Test func supportsTrashHandlesErrorFromCurrentCapabilities() async throws { + await RetrievedCapabilitiesActor.shared.reset() + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + return (acc.ncKitAccount, nil, nil, .invalidResponseError) + } + // Ensure fetch is triggered + // (e.g., actor has no data or stale data for testAccount.ncKitAccount) + + let result = await remoteInterface.supportsTrash(account: testAccount) + #expect(!result, "supportsTrash should return false if currentCapabilities errors.") + } +} From f73f20dc983395de13ffe056c5d797f70a53c790 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 28 May 2025 17:40:19 +0800 Subject: [PATCH 15/15] Fix CI nitpick failing the entire pipeline Signed-off-by: Claudio Cambra --- Tests/Interface/Item+Init.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Interface/Item+Init.swift b/Tests/Interface/Item+Init.swift index 01550a93..e322e3a3 100644 --- a/Tests/Interface/Item+Init.swift +++ b/Tests/Interface/Item+Init.swift @@ -15,7 +15,7 @@ public extension Item { parentItemIdentifier: NSFileProviderItemIdentifier, account: Account, remoteInterface: RemoteInterface, - dbManager: FilesDatabaseManager, + dbManager: FilesDatabaseManager ) { self.init( metadata: metadata,