diff --git a/Package.swift b/Package.swift index 2b18601c..c3bcdcb2 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/nextcloud/NextcloudCapabilitiesKit.git", from: "2.3.0"), - .package(url: "https://github.com/nextcloud/NextcloudKit", from: "7.0.0"), + .package(url: "https://github.com/nextcloud/NextcloudKit", exact: "7.2.2"), .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.55.0"), .package(url: "https://github.com/realm/realm-swift.git", from: "20.0.1"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0") diff --git a/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift b/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift index caa63a67..4298d79b 100644 --- a/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift +++ b/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift @@ -208,9 +208,14 @@ public final class FilesDatabaseManager: Sendable { if let itemMetadata = itemMetadatas.where({ $0.ocId == ocId }).first { return SendableItemMetadata(value: itemMetadata) } + return nil } + public func itemMetadata(_ identifier: NSFileProviderItemIdentifier) -> SendableItemMetadata? { + itemMetadata(ocId: identifier.rawValue) + } + /// /// Look up the item metadata by its account identifier and remote address. /// @@ -259,6 +264,19 @@ public final class FilesDatabaseManager: Sendable { return nil } + /// + /// Fetch the metadata object for the root container of the given account. + /// + /// This is useful for when you have only the `NSFileProviderItemIdentifier.rootContainer` but no `ocId` to look up metadata by. + /// + public func rootItemMetadata(account: Account) -> SendableItemMetadata? { + guard let object = itemMetadatas.where({ $0.account == account.ncKitAccount && $0.directory && $0.path == Account.webDavFilesUrlSuffix }).first else { + return nil + } + + return SendableItemMetadata(value: object) + } + public func itemMetadatas(account: String) -> [SendableItemMetadata] { itemMetadatas .where { $0.account == account } @@ -273,12 +291,6 @@ public final class FilesDatabaseManager: Sendable { .toUnmanagedResults() } - public func itemMetadataFromFileProviderItemIdentifier( - _ identifier: NSFileProviderItemIdentifier - ) -> SendableItemMetadata? { - itemMetadata(ocId: identifier.rawValue) - } - private func processItemMetadatasToDelete( existingMetadatas: Results, updatedMetadatas: [SendableItemMetadata] @@ -607,13 +619,23 @@ public final class FilesDatabaseManager: Sendable { } private func managedMaterialisedItemMetadatas(account: String) -> Results { - itemMetadatas - .where { - $0.account == account && - (($0.directory && $0.visitedDirectory) || (!$0.directory && $0.downloaded)) - } + itemMetadatas.where { candidate in + let belongsToAccount = candidate.account == account + let isVisitedDirectory = candidate.directory && candidate.visitedDirectory + let isDownloadedFile = candidate.directory == false && candidate.downloaded + + return belongsToAccount && (isVisitedDirectory || isDownloadedFile) + } } + /// + /// Return metadata for materialized file provider items. + /// + /// - Parameters: + /// - account: The account identifier to filter by. + /// + /// - Returns: An array of sendable metadata objects. + /// public func materialisedItemMetadatas(account: String) -> [SendableItemMetadata] { managedMaterialisedItemMetadatas(account: account).toUnmanagedResults() } diff --git a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift index 63e1a06a..ad20a88b 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift @@ -10,9 +10,6 @@ import NextcloudKit public class Enumerator: NSObject, NSFileProviderEnumerator { let enumeratedItemIdentifier: NSFileProviderItemIdentifier private var enumeratedItemMetadata: SendableItemMetadata? - private var enumeratingSystemIdentifier: Bool { - Self.isSystemIdentifier(enumeratedItemIdentifier) - } let domain: NSFileProviderDomain? let dbManager: FilesDatabaseManager @@ -51,7 +48,7 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { serverUrl = account.davFilesUrl } else { logger.debug("Providing enumerator for item with identifier.", [.item: enumeratedItemIdentifier]) - enumeratedItemMetadata = dbManager.itemMetadataFromFileProviderItemIdentifier( + enumeratedItemMetadata = dbManager.itemMetadata( enumeratedItemIdentifier) if let enumeratedItemMetadata { diff --git a/Sources/NextcloudFileProviderKit/Enumeration/MaterialisedEnumerationObserver.swift b/Sources/NextcloudFileProviderKit/Enumeration/MaterialisedEnumerationObserver.swift deleted file mode 100644 index b24ad3fa..00000000 --- a/Sources/NextcloudFileProviderKit/Enumeration/MaterialisedEnumerationObserver.swift +++ /dev/null @@ -1,118 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later - -import FileProvider -import Foundation -import RealmSwift - -/// -/// The custom `NSFileProviderEnumerationObserver` implementation to process materialized items enumerated by the system. -/// -public class MaterialisedEnumerationObserver: NSObject, NSFileProviderEnumerationObserver { - let logger: FileProviderLogger - public let ncKitAccount: String - let dbManager: FilesDatabaseManager - private let completionHandler: ( - _ materialisedIds: Set, _ unmaterialisedIds: Set - ) -> Void - private var allEnumeratedItemIds = Set() - - public required init( - ncKitAccount: String, - dbManager: FilesDatabaseManager, - log: any FileProviderLogging, - completionHandler: @escaping ( - _ materialisedIds: Set, _ unmaterialisedIds: Set - ) -> Void - ) { - self.ncKitAccount = ncKitAccount - self.dbManager = dbManager - logger = FileProviderLogger(category: "MaterialisedEnumerationObserver", log: log) - self.completionHandler = completionHandler - super.init() - } - - public func didEnumerate(_ updatedItems: [NSFileProviderItemProtocol]) { - updatedItems.map(\.itemIdentifier.rawValue).forEach { allEnumeratedItemIds.insert($0) } - } - - public func finishEnumerating(upTo _: NSFileProviderPage?) { - logger.debug("Handling enumerated materialised items.") - - handleEnumeratedItems( - allEnumeratedItemIds, - account: ncKitAccount, - dbManager: dbManager, - completionHandler: completionHandler - ) - } - - public func finishEnumeratingWithError(_ error: Error) { - logger.error("Finishing enumeration with error.", [.error: error]) - - handleEnumeratedItems( - allEnumeratedItemIds, - account: ncKitAccount, - dbManager: dbManager, - completionHandler: completionHandler - ) - } - - func handleEnumeratedItems( - _ itemIds: Set, - account: String, - dbManager: FilesDatabaseManager, - completionHandler: @escaping ( - _ materialisedIds: Set, _ unmaterialisedIds: Set - ) -> Void - ) { - let materialisedMetadatas = dbManager.materialisedItemMetadatas(account: account) - var materialisedMetadatasMap = [String: SendableItemMetadata]() - var unmaterialisedIds = Set() - var newMaterialisedIds = Set() - - for materialisedMetadata in materialisedMetadatas { - materialisedMetadatasMap[materialisedMetadata.ocId] = materialisedMetadata - unmaterialisedIds.insert(materialisedMetadata.ocId) - } - - for enumeratedId in itemIds { - if unmaterialisedIds.contains(enumeratedId) { - unmaterialisedIds.remove(enumeratedId) - } else { - newMaterialisedIds.insert(enumeratedId) - - guard var metadata = dbManager.itemMetadata(ocId: enumeratedId) else { - logger.error("No metadata for enumerated item found.", [.item: enumeratedId]) - continue - } - - if metadata.directory { - metadata.visitedDirectory = true - } else { - metadata.downloaded = true - } - - logger.info("Updating state for item to materialized.", [.item: enumeratedId, .name: metadata.fileName]) - dbManager.addItemMetadata(metadata) - } - } - - for unmaterialisedId in unmaterialisedIds { - guard var metadata = materialisedMetadatasMap[unmaterialisedId] else { - logger.error("No materialized found.", [.item: unmaterialisedId]) - continue - } - - logger.info("Updating state for item to dataless.", [.name: metadata.fileName, .item: unmaterialisedId]) - - metadata.downloaded = false - metadata.visitedDirectory = false - dbManager.addItemMetadata(metadata) - } - - // TODO: Do we need to signal the working set now? Unclear - - completionHandler(newMaterialisedIds, unmaterialisedIds) - } -} diff --git a/Sources/NextcloudFileProviderKit/Enumeration/MaterializedEnumerationObserver.swift b/Sources/NextcloudFileProviderKit/Enumeration/MaterializedEnumerationObserver.swift new file mode 100644 index 00000000..72d1e70c --- /dev/null +++ b/Sources/NextcloudFileProviderKit/Enumeration/MaterializedEnumerationObserver.swift @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-2.0-or-later + +import FileProvider +import Foundation +import RealmSwift + +/// +/// The custom `NSFileProviderEnumerationObserver` implementation to process materialized items enumerated by the system. +/// +public class MaterializedEnumerationObserver: NSObject, NSFileProviderEnumerationObserver { + let logger: FileProviderLogger + public let account: Account + let dbManager: FilesDatabaseManager + private let completionHandler: (_ materialized: Set, _ evicted: Set) -> Void + + /// + /// All materialized items enumerated by the system. + /// + private var enumeratedItems = Set() + + public required init(account: Account, dbManager: FilesDatabaseManager, log: any FileProviderLogging, completionHandler: @escaping (_ materialized: Set, _ evicted: Set) -> Void) { + self.account = account + self.dbManager = dbManager + logger = FileProviderLogger(category: "MaterializedEnumerationObserver", log: log) + self.completionHandler = completionHandler + super.init() + } + + public func didEnumerate(_ updatedItems: [NSFileProviderItemProtocol]) { + updatedItems + .map(\.itemIdentifier) + .forEach { enumeratedItems.insert($0) } + } + + public func finishEnumerating(upTo _: NSFileProviderPage?) { + logger.debug("Handling enumerated materialized items.") + handleEnumeratedItems(enumeratedItems, account: account, dbManager: dbManager, completionHandler: completionHandler) + } + + public func finishEnumeratingWithError(_ error: Error) { + logger.error("Finishing enumeration with error.", [.error: error]) + handleEnumeratedItems(enumeratedItems, account: account, dbManager: dbManager, completionHandler: completionHandler) + } + + func handleEnumeratedItems(_ identifiers: Set, account: Account, dbManager: FilesDatabaseManager, completionHandler: @escaping (_ materialized: Set, _ evicted: Set) -> Void) { + let metadataForMaterializedItems = dbManager.materialisedItemMetadatas(account: account.ncKitAccount) + var metadataForMaterializedItemsByIdentifier = [NSFileProviderItemIdentifier: SendableItemMetadata]() + var evictedItems = Set() + var stillMaterializedItems = Set() + + for metadata in metadataForMaterializedItems { + let identifier = NSFileProviderItemIdentifier(metadata.ocId) + metadataForMaterializedItemsByIdentifier[identifier] = metadata + evictedItems.insert(identifier) // Assume the item related to the metadata object was evicted until proven otherwise below. + } + + for enumeratedIdentifier in identifiers { + if evictedItems.contains(enumeratedIdentifier) { + evictedItems.remove(enumeratedIdentifier) // The enumerated item cannot be assumed as evicted any longer. + } else { + stillMaterializedItems.insert(enumeratedIdentifier) + + guard var metadata = if enumeratedIdentifier == .rootContainer { + dbManager.rootItemMetadata(account: account) + } else { + dbManager.itemMetadata(enumeratedIdentifier) + } else { + logger.error("No metadata for enumerated item found.", [.item: enumeratedIdentifier]) + continue + } + + if metadata.directory { + metadata.visitedDirectory = true + } else { + metadata.downloaded = true + } + + logger.info("Updating state for item to materialized.", [.item: enumeratedIdentifier, .name: metadata.fileName]) + dbManager.addItemMetadata(metadata) + } + } + + for evictedItemIdentifier in evictedItems { + guard var metadata = metadataForMaterializedItemsByIdentifier[evictedItemIdentifier] else { + logger.error("No metadata found for apparently evicted identifier.", [.item: evictedItemIdentifier]) + continue + } + + logger.info("Updating item state to dataless.", [.name: metadata.fileName, .item: evictedItemIdentifier]) + + metadata.downloaded = false + metadata.visitedDirectory = false + dbManager.addItemMetadata(metadata) + } + + completionHandler(stillMaterializedItems, evictedItems) + } +} diff --git a/Sources/NextcloudFileProviderKit/Enumeration/RemoteChangeObserver.swift b/Sources/NextcloudFileProviderKit/Enumeration/RemoteChangeObserver.swift index 45790036..f34d858c 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/RemoteChangeObserver.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/RemoteChangeObserver.swift @@ -9,13 +9,12 @@ import NextcloudKit public let NotifyPushAuthenticatedNotificationName = Notification.Name("NotifyPushAuthenticated") -public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSessionWebSocketDelegate { +public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSessionWebSocketDelegate, @unchecked Sendable { public let remoteInterface: RemoteInterface public let changeNotificationInterface: ChangeNotificationInterface public let domain: NSFileProviderDomain? public let dbManager: FilesDatabaseManager public var account: Account - public var accountId: String { account.ncKitAccount } public var webSocketPingIntervalNanoseconds: UInt64 = 3 * 1_000_000_000 public var webSocketReconfigureIntervalNanoseconds: UInt64 = 1 * 1_000_000_000 @@ -121,7 +120,7 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess return } guard webSocketAuthenticationFailCount < webSocketAuthenticationFailLimit else { - logger.error("Exceeded authentication failures for notify push websocket \(accountId), will poll instead.", [.account: accountId]) + logger.error("Exceeded authentication failures for notify push websocket \(account.ncKitAccount), will poll instead.", [.account: account.ncKitAccount]) startPollingTimer() return } @@ -159,7 +158,7 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess ) guard error == .success else { - logger.error("Could not get capabilities: \(error.errorCode) \(error.errorDescription)", [.account: accountId]) + logger.error("Could not get capabilities: \(error.errorCode) \(error.errorDescription)", [.account: account.ncKitAccount]) reconnectWebSocket() return } @@ -167,7 +166,7 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess guard let capabilities, let websocketEndpoint = capabilities.notifyPush?.endpoints?.websocket else { - logger.error("Could not get notifyPush websocket \(accountId), polling.", [.account: accountId]) + logger.error("Could not get notifyPush websocket \(account.ncKitAccount), polling.", [.account: account.ncKitAccount]) startPollingTimer() return } @@ -185,7 +184,7 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess ) webSocketTask = webSocketUrlSession?.webSocketTask(with: websocketEndpointUrl) webSocketTask?.resume() - logger.info("Successfully configured push notifications for \(accountId)", [.account: accountId]) + logger.info("Successfully configured push notifications for \(account.ncKitAccount)", [.account: account.ncKitAccount]) } public func authenticationChallenge( @@ -225,7 +224,7 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess didOpenWithProtocol _: String? ) { guard !invalidated else { return } - logger.debug("Websocket connected \(accountId) sending auth details", [.account: accountId]) + logger.debug("Websocket connected \(account.ncKitAccount) sending auth details", [.account: account.ncKitAccount]) Task { await authenticateWebSocket() } } @@ -243,13 +242,13 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess return } - logger.debug("Socket connection closed for \(accountId).", [.account: accountId]) + logger.debug("Socket connection closed for \(account.ncKitAccount).", [.account: account.ncKitAccount]) if let reason { logger.debug("Reason: \(String(data: reason, encoding: .utf8) ?? "")") } - logger.debug("Retrying websocket connection for \(accountId).", [.account: accountId]) + logger.debug("Retrying websocket connection for \(account.ncKitAccount).", [.account: account.ncKitAccount]) reconnectWebSocket() } @@ -262,7 +261,7 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess try await webSocketTask?.send(.string(account.username)) try await webSocketTask?.send(.string(account.password)) } catch { - logger.error("Error authenticating websocket.", [.account: accountId, .error: error]) + logger.error("Error authenticating websocket.", [.account: account.ncKitAccount, .error: error]) } readWebSocket() @@ -279,7 +278,7 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess do { try await Task.sleep(nanoseconds: self.webSocketPingIntervalNanoseconds) } catch { - self.logger.error("Could not sleep websocket ping.", [.account: self.accountId, .error: error]) + self.logger.error("Could not sleep websocket ping.", [.account: self.account.ncKitAccount, .error: error]) } guard !Task.isCancelled else { return } self.pingWebSocket() @@ -289,7 +288,7 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess private func pingWebSocket() { // Keep the socket connection alive guard !invalidated else { return } guard networkReachability != .notReachable else { - logger.error("Not pinging because network is unreachable.", [.account: accountId]) + logger.error("Not pinging because network is unreachable.", [.account: account.ncKitAccount]) return } @@ -315,7 +314,7 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess webSocketTask?.receive { result in switch result { case .failure: - self.logger.debug("Failed to read websocket \(self.accountId)", [.account: self.accountId]) + self.logger.debug("Failed to read websocket \(self.account.ncKitAccount)", [.account: self.account.ncKitAccount]) // Do not reconnect here, delegate methods will handle reconnecting case let .success(message): switch message { @@ -334,7 +333,7 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess private func processWebsocket(data: Data) { guard !invalidated else { return } guard let string = String(data: data, encoding: .utf8) else { - logger.error("Could parse websocket data for id: \(accountId)", [.account: accountId]) + logger.error("Could parse websocket data for id: \(account.ncKitAccount)", [.account: account.ncKitAccount]) return } processWebsocket(string: string) @@ -343,14 +342,14 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess private func processWebsocket(string: String) { logger.debug("Received websocket string: \(string)") if string == "notify_file" { - logger.debug("Received file notification for \(accountId)", [.account: accountId]) + logger.debug("Received file notification for \(account.ncKitAccount)", [.account: account.ncKitAccount]) startWorkingSetCheck() } else if string == "notify_activity" { - logger.debug("Ignoring activity notification: \(accountId)", [.account: accountId]) + logger.debug("Ignoring activity notification: \(account.ncKitAccount)", [.account: account.ncKitAccount]) } else if string == "notify_notification" { - logger.debug("Ignoring notification: \(accountId)", [.account: accountId]) + logger.debug("Ignoring notification: \(account.ncKitAccount)", [.account: account.ncKitAccount]) } else if string == "authenticated" { - logger.debug("Correctly authed websocket \(accountId), pinging", [.account: accountId]) + logger.debug("Correctly authed websocket \(account.ncKitAccount), pinging", [.account: account.ncKitAccount]) NotificationCenter.default.post( name: NotifyPushAuthenticatedNotificationName, object: self ) @@ -358,14 +357,14 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess } else if string == "err: Invalid credentials" { logger.debug( """ - Invalid creds for websocket for \(accountId), + Invalid creds for websocket for \(account.ncKitAccount), reattempting auth. """ ) webSocketAuthenticationFailCount += 1 reconnectWebSocket() } else { - logger.error("Received unknown string from websocket \(accountId): \(string)", [.account: accountId]) + logger.error("Received unknown string from websocket \(account.ncKitAccount): \(string)", [.account: account.ncKitAccount]) } } @@ -455,7 +454,7 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess // Visited folders and downloaded files. Sort in terms of their remote URLs. // This way we ensure we visit parent folders before their children. let materialisedItems = dbManager - .materialisedItemMetadatas(account: accountId) + .materialisedItemMetadatas(account: account.ncKitAccount) .sorted { $0.remotePath().count < $1.remotePath().count } var allNewMetadatas = [SendableItemMetadata]() @@ -502,7 +501,7 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess examinedItemIds.insert(item.ocId) } } else if let readError, readError != .success { - logger.info("Finished change enumeration of working set for user \(accountId) with error.", [.account: accountId, .error: readError]) + logger.info("Finished change enumeration of working set for user \(account.ncKitAccount) with error.", [.account: account.ncKitAccount, .error: readError]) return } else { allDeletedMetadatas += deletedMetadatas ?? [] diff --git a/Sources/NextcloudFileProviderKit/Interface/ChangeNotificationInterface.swift b/Sources/NextcloudFileProviderKit/Interface/ChangeNotificationInterface.swift index cf2294b8..5cf59d1e 100644 --- a/Sources/NextcloudFileProviderKit/Interface/ChangeNotificationInterface.swift +++ b/Sources/NextcloudFileProviderKit/Interface/ChangeNotificationInterface.swift @@ -3,6 +3,6 @@ import Foundation -public protocol ChangeNotificationInterface { +public protocol ChangeNotificationInterface: Sendable { func notifyChange() } diff --git a/Sources/NextcloudFileProviderKit/Interface/FileProviderChangeNotificationInterface.swift b/Sources/NextcloudFileProviderKit/Interface/FileProviderChangeNotificationInterface.swift index bae1c7c2..74a0be9e 100644 --- a/Sources/NextcloudFileProviderKit/Interface/FileProviderChangeNotificationInterface.swift +++ b/Sources/NextcloudFileProviderKit/Interface/FileProviderChangeNotificationInterface.swift @@ -1,10 +1,10 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors // SPDX-License-Identifier: GPL-2.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation -public class FileProviderChangeNotificationInterface: ChangeNotificationInterface { +public final class FileProviderChangeNotificationInterface: ChangeNotificationInterface { let domain: NSFileProviderDomain let logger: FileProviderLogger diff --git a/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift b/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift index 2cc9faac..f14e54f9 100644 --- a/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift +++ b/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift @@ -23,7 +23,7 @@ public enum AuthenticationAttemptResultState: Int { /// Usually, the shared `NextcloudKit` instance is conforming to this and provided as an argument. /// NextcloudKit is not mockable as of writing, hence this protocol was defined to enable testing. /// -public protocol RemoteInterface { +public protocol RemoteInterface: Sendable { func setDelegate(_ delegate: NextcloudKitDelegate) func createFolder( diff --git a/Sources/NextcloudFileProviderKit/Item/Item.swift b/Sources/NextcloudFileProviderKit/Item/Item.swift index 66b14188..dcfda6e4 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item.swift @@ -374,7 +374,7 @@ public class Item: NSObject, NSFileProviderItem { ) } - guard let metadata = dbManager.itemMetadataFromFileProviderItemIdentifier(identifier) else { + guard let metadata = dbManager.itemMetadata(identifier) else { return nil } diff --git a/Sources/NextcloudFileProviderKit/Log/FileProviderLog.swift b/Sources/NextcloudFileProviderKit/Log/FileProviderLog.swift index ff8b73bf..505b523b 100644 --- a/Sources/NextcloudFileProviderKit/Log/FileProviderLog.swift +++ b/Sources/NextcloudFileProviderKit/Log/FileProviderLog.swift @@ -223,6 +223,8 @@ public actor FileProviderLog: FileProviderLogging { account.ncKitAccount case let date as Date: messageDateFormatter.string(from: date) + case let error as NSError: + error.debugDescription case let lock as NKLock: lock.token ?? "nil" case let item as NSFileProviderItem: diff --git a/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift b/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift index 0159ad68..fa25e47a 100644 --- a/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift +++ b/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift @@ -22,9 +22,7 @@ extension [SendableItemMetadata] { return nil } - guard let parentItemIdentifier = dbManager.parentItemIdentifierFromMetadata( - itemMetadata - ) else { + guard let parentItemIdentifier = dbManager.parentItemIdentifierFromMetadata(itemMetadata) else { logger.error("Could not get valid parentItemIdentifier for item by ocId.", [.item: itemMetadata.ocId, .name: itemMetadata.fileName]) let targetUrl = itemMetadata.serverUrl throw FilesDatabaseManager.parentMetadataNotFoundError(itemUrl: targetUrl) diff --git a/Sources/NextcloudFileProviderKitMocks/TestableRemoteInterface.swift b/Sources/NextcloudFileProviderKitMocks/TestableRemoteInterface.swift index d04e3cf0..9b2c3705 100644 --- a/Sources/NextcloudFileProviderKitMocks/TestableRemoteInterface.swift +++ b/Sources/NextcloudFileProviderKitMocks/TestableRemoteInterface.swift @@ -7,7 +7,7 @@ import NextcloudCapabilitiesKit @testable import NextcloudFileProviderKit import NextcloudKit -public struct TestableRemoteInterface: RemoteInterface { +public struct TestableRemoteInterface: RemoteInterface, @unchecked Sendable { public init() {} public func setDelegate(_: any NextcloudKitDelegate) {} @@ -78,13 +78,13 @@ public struct TestableRemoteInterface: RemoteInterface { ) async -> (account: String, data: Data?, error: NKError) { ("", nil, .invalidResponseError) } public func downloadAsync( - serverUrlFileName: Any, - fileNameLocalPath: String, - account: String, - options: NKRequestOptions, - requestHandler: @escaping (_ request: DownloadRequest) -> Void, - taskHandler: @escaping (_ task: URLSessionTask) -> Void, - progressHandler: @escaping (_ progress: Progress) -> Void + serverUrlFileName _: Any, + fileNameLocalPath _: String, + account _: String, + options _: NKRequestOptions, + requestHandler _: @escaping (_ request: DownloadRequest) -> Void, + taskHandler _: @escaping (_ task: URLSessionTask) -> Void, + progressHandler _: @escaping (_ progress: Progress) -> Void ) async -> ( account: String, etag: String?, diff --git a/Tests/Interface/MockChangeNotificationInterface.swift b/Tests/Interface/MockChangeNotificationInterface.swift index 8ddce1d3..fe605aa8 100644 --- a/Tests/Interface/MockChangeNotificationInterface.swift +++ b/Tests/Interface/MockChangeNotificationInterface.swift @@ -4,7 +4,7 @@ import Foundation import NextcloudFileProviderKit -public class MockChangeNotificationInterface: ChangeNotificationInterface { +public class MockChangeNotificationInterface: ChangeNotificationInterface, @unchecked Sendable { public var changeHandler: (() -> Void)? public init(changeHandler: (() -> Void)? = nil) { self.changeHandler = changeHandler diff --git a/Tests/Interface/MockNotifyPushServer.swift b/Tests/Interface/MockNotifyPushServer.swift index ea6fdb46..e05f1c71 100644 --- a/Tests/Interface/MockNotifyPushServer.swift +++ b/Tests/Interface/MockNotifyPushServer.swift @@ -7,7 +7,7 @@ import NIOPosix import NIOWebSocket @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -public class MockNotifyPushServer { +public class MockNotifyPushServer: @unchecked Sendable { /// The server's host. private let host: String /// The server's port. diff --git a/Tests/Interface/MockRemoteInterface.swift b/Tests/Interface/MockRemoteInterface.swift index 34aa462b..28f4f5a2 100644 --- a/Tests/Interface/MockRemoteInterface.swift +++ b/Tests/Interface/MockRemoteInterface.swift @@ -554,7 +554,7 @@ let mockCapabilities = ##""" } """## -public class MockRemoteInterface: RemoteInterface { +public class MockRemoteInterface: RemoteInterface, @unchecked Sendable { /// /// `RemoteInterface` makes it necessary to bypass its API to fully register a mocked account object. /// @@ -996,10 +996,10 @@ public class MockRemoteInterface: RemoteInterface { serverUrlFileName: Any, fileNameLocalPath: String, account: String, - options: NKRequestOptions, - requestHandler: @escaping (_ request: DownloadRequest) -> Void, - taskHandler: @escaping (_ task: URLSessionTask) -> Void, - progressHandler: @escaping (_ progress: Progress) -> Void + options _: NKRequestOptions, + requestHandler _: @escaping (_ request: DownloadRequest) -> Void = { _ in }, + taskHandler _: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + progressHandler _: @escaping (_ progress: Progress) -> Void = { _ in } ) async -> ( account: String, etag: String?, diff --git a/Tests/InterfaceTests/MockRemoteInterfaceTests.swift b/Tests/InterfaceTests/MockRemoteInterfaceTests.swift index 5d5a42ab..1b55a80d 100644 --- a/Tests/InterfaceTests/MockRemoteInterfaceTests.swift +++ b/Tests/InterfaceTests/MockRemoteInterfaceTests.swift @@ -383,24 +383,31 @@ final class MockRemoteInterfaceTests: XCTestCase { func testDownload() async throws { let remoteInterface = MockRemoteInterface(rootItem: rootItem) + remoteInterface.injectMock(Self.account) + debugPrint(remoteInterface) - let fileUrl = FileManager.default.temporaryDirectory.appendingPathComponent( - "file.txt", conformingTo: .text - ) + + let fileUrl = FileManager.default.temporaryDirectory.appendingPathComponent("file.txt", conformingTo: .text) + if !FileManager.default.isWritableFile(atPath: fileUrl.path) { print("WARNING: TEMP NOT WRITEABLE. SKIPPING TEST") return } + let fileData = Data("Hello, World!".utf8) _ = await remoteInterface.upload( - remotePath: Self.account.davFilesUrl + "/file.txt", localPath: fileUrl.path, account: Self.account - ) - - let result = await remoteInterface.download( remotePath: Self.account.davFilesUrl + "/file.txt", localPath: fileUrl.path, account: Self.account ) + + let result = await remoteInterface.downloadAsync( + serverUrlFileName: Self.account.davFilesUrl + "/file.txt", + fileNameLocalPath: fileUrl.path, + account: Self.account.ncKitAccount, + options: .init() + ) + XCTAssertEqual(result.nkError, .success) let downloadedData = try Data(contentsOf: fileUrl) diff --git a/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift b/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift index 142803a9..cd938dde 100644 --- a/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift +++ b/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift @@ -223,7 +223,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { // 3. Assert XCTAssertNil(observer.error, "Enumeration should complete without error.") - XCTAssertEqual(observer.items.count, 2, "Should only enumerate the 2 materialised items.") + XCTAssertEqual(observer.items.count, 2, "Should only enumerate the 2 materialized items.") let enumeratedIds = Set(observer.items.map(\.itemIdentifier.rawValue)) XCTAssertTrue( @@ -247,7 +247,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { func testWorkingSetEnumerationWhenNoMaterialisedItems() async throws { // This test verifies that the enumerator behaves correctly when there are - // no materialised items in the database. + // no materialized items in the database. // 1. Arrange let db = Self.dbManager.ncDatabase() diff --git a/Tests/NextcloudFileProviderKitTests/FilesDatabaseManagerTests.swift b/Tests/NextcloudFileProviderKitTests/FilesDatabaseManagerTests.swift index e840427d..fe4ab456 100644 --- a/Tests/NextcloudFileProviderKitTests/FilesDatabaseManagerTests.swift +++ b/Tests/NextcloudFileProviderKitTests/FilesDatabaseManagerTests.swift @@ -1186,11 +1186,11 @@ final class FilesDatabaseManagerTests: NextcloudFileProviderKitTestCase { Self.dbManager.addItemMetadata(sItemC) Self.dbManager.addItemMetadata(sDirD) - let materialised = + let materialized = Self.dbManager.materialisedItemMetadatas(account: Self.account.ncKitAccount) - XCTAssertEqual(materialised.count, 5) + XCTAssertEqual(materialized.count, 5) - let materialisedOcIds = materialised.map(\.ocId) + let materialisedOcIds = materialized.map(\.ocId) XCTAssertTrue(materialisedOcIds.contains(itemA.ocId)) XCTAssertTrue(materialisedOcIds.contains(folderA.ocId)) XCTAssertTrue(materialisedOcIds.contains(sItemA.ocId)) @@ -1249,7 +1249,7 @@ final class FilesDatabaseManagerTests: NextcloudFileProviderKitTestCase { // --- Multi-level file structure to test the full scope --- - // LEVEL 1: Root level materialised folder updated recently + // LEVEL 1: Root level materialized folder updated recently var updatedDir = SendableItemMetadata(ocId: "updatedDir", fileName: "UpdatedDir", account: Self.account) updatedDir.directory = true updatedDir.visitedDirectory = true // Materialised @@ -1267,7 +1267,7 @@ final class FilesDatabaseManagerTests: NextcloudFileProviderKitTestCase { var childOfUpdatedDirRecent = SendableItemMetadata(ocId: "childOfUpdatedRecent", fileName: "childRecent.txt", account: Self.account) childOfUpdatedDirRecent.serverUrl = Self.account.davFilesUrl + "/UpdatedDir" childOfUpdatedDirRecent.syncTime = recentSyncDate // Recent sync time - childOfUpdatedDirRecent.downloaded = false // NOT materialised - but should still be included + childOfUpdatedDirRecent.downloaded = false // NOT materialized - but should still be included Self.dbManager.addItemMetadata(childOfUpdatedDirRecent) // LEVEL 2: Child folder of updated folder with recent sync time @@ -1285,7 +1285,7 @@ final class FilesDatabaseManagerTests: NextcloudFileProviderKitTestCase { grandchildOfUpdated.downloaded = false // Not materialised Self.dbManager.addItemMetadata(grandchildOfUpdated) - // DELETED STRUCTURE: Root level materialised folder deleted recently + // DELETED STRUCTURE: Root level materialized folder deleted recently var deletedDir = SendableItemMetadata(ocId: "deletedDir", fileName: "DeletedDir", account: Self.account) deletedDir.directory = true deletedDir.visitedDirectory = true // Materialised @@ -1322,32 +1322,32 @@ final class FilesDatabaseManagerTests: NextcloudFileProviderKitTestCase { deepNestedInDeleted.downloaded = false Self.dbManager.addItemMetadata(deepNestedInDeleted) - // STANDALONE ITEMS: Materialised file synced recently - should be returned + // STANDALONE ITEMS: materialized file synced recently - should be returned var standaloneUpdatedFile = SendableItemMetadata(ocId: "standaloneUpdated", fileName: "standalone.txt", account: Self.account) standaloneUpdatedFile.downloaded = true // Materialised standaloneUpdatedFile.syncTime = recentSyncDate Self.dbManager.addItemMetadata(standaloneUpdatedFile) - // Materialised file synced too long ago - should NOT be returned + // materialized file synced too long ago - should NOT be returned var standaloneOldFile = SendableItemMetadata(ocId: "standaloneOld", fileName: "old.txt", account: Self.account) standaloneOldFile.downloaded = true // Materialised standaloneOldFile.syncTime = oldSyncDate Self.dbManager.addItemMetadata(standaloneOldFile) - // Non-materialised item synced recently - should NOT be returned (not in initial materialised set) + // Non-materialised item synced recently - should NOT be returned (not in initial materialized set) var nonMaterialisedFile = SendableItemMetadata(ocId: "nonMaterialised", fileName: "non-mat.txt", account: Self.account) nonMaterialisedFile.downloaded = false nonMaterialisedFile.syncTime = recentSyncDate Self.dbManager.addItemMetadata(nonMaterialisedFile) - // MIXED MATERIALISATION: Another materialised folder to test child inclusion + // MIXED MATERIALISATION: Another materialized folder to test child inclusion var anotherMaterialisedDir = SendableItemMetadata(ocId: "anotherMatDir", fileName: "AnotherMatDir", account: Self.account) anotherMaterialisedDir.directory = true anotherMaterialisedDir.visitedDirectory = true anotherMaterialisedDir.syncTime = recentSyncDate Self.dbManager.addItemMetadata(anotherMaterialisedDir) - // Child with recent sync but NOT materialised - should still be included due to recent sync + // Child with recent sync but NOT materialized - should still be included due to recent sync var nonMatChildRecent = SendableItemMetadata(ocId: "nonMatChildRecent", fileName: "nonMatChild.txt", account: Self.account) nonMatChildRecent.serverUrl = Self.account.davFilesUrl + "/AnotherMatDir" nonMatChildRecent.syncTime = recentSyncDate @@ -1362,10 +1362,10 @@ final class FilesDatabaseManagerTests: NextcloudFileProviderKitTestCase { // 3. Assert - Updated items let updatedIds = Set(result.updated.map(\.ocId)) - // Should include materialised items with recent sync - XCTAssertTrue(updatedIds.contains("updatedDir"), "Updated materialised directory should be included") - XCTAssertTrue(updatedIds.contains("standaloneUpdated"), "Updated materialised file should be included") - XCTAssertTrue(updatedIds.contains("anotherMatDir"), "Another materialised directory should be included") + // Should include materialized items with recent sync + XCTAssertTrue(updatedIds.contains("updatedDir"), "Updated materialized directory should be included") + XCTAssertTrue(updatedIds.contains("standaloneUpdated"), "Updated materialized file should be included") + XCTAssertTrue(updatedIds.contains("anotherMatDir"), "Another materialized directory should be included") // Should include children with recent sync regardless of materialisation XCTAssertTrue(updatedIds.contains("childOfUpdatedRecent"), "Child with recent sync should be included regardless of materialisation") @@ -1382,8 +1382,8 @@ final class FilesDatabaseManagerTests: NextcloudFileProviderKitTestCase { // 4. Assert - Deleted items let deletedIds = Set(result.deleted.map(\.ocId)) - // Should include the deleted materialised directory - XCTAssertTrue(deletedIds.contains("deletedDir"), "Deleted materialised directory should be included") + // Should include the deleted materialized directory + XCTAssertTrue(deletedIds.contains("deletedDir"), "Deleted materialized directory should be included") // Should include children/descendants with recent sync under deleted paths XCTAssertTrue(deletedIds.contains("childOfDeletedRecent"), "Child of deleted dir with recent sync should be included") diff --git a/Tests/NextcloudFileProviderKitTests/MaterialisedEnumerationObserverTests.swift b/Tests/NextcloudFileProviderKitTests/MaterialisedEnumerationObserverTests.swift index cf93d1fb..122055d4 100644 --- a/Tests/NextcloudFileProviderKitTests/MaterialisedEnumerationObserverTests.swift +++ b/Tests/NextcloudFileProviderKitTests/MaterialisedEnumerationObserverTests.swift @@ -35,21 +35,21 @@ final class MaterialisedEnumerationObserverTests: NextcloudFileProviderKitTestCa let expect = XCTestExpectation(description: "Enumerator completion handler called") // The observer's logic requires metadata to exist in the DB to update it. - let observer = MaterialisedEnumerationObserver(ncKitAccount: Self.account.ncKitAccount, dbManager: dbManager, log: FileProviderLogMock()) { newlyMaterialisedIds, unmaterialisedIds in + let observer = MaterializedEnumerationObserver(account: Self.account, dbManager: dbManager, log: FileProviderLogMock()) { newlyMaterialisedIds, unmaterialisedIds in XCTAssertTrue( unmaterialisedIds.isEmpty, "Unmaterialised set should be empty when DB starts empty." ) - // The items are correctly identified as newly materialised because they weren't in the - // DB's materialised list (which was empty). + // The items are correctly identified as newly materialized because they weren't in the + // DB's materialized list (which was empty). XCTAssertEqual( newlyMaterialisedIds.count, 2, "Both enumerated items should be identified as newly materialised." ) - XCTAssertTrue(newlyMaterialisedIds.contains("file1")) - XCTAssertTrue(newlyMaterialisedIds.contains("dir1")) + XCTAssertTrue(newlyMaterialisedIds.contains(NSFileProviderItemIdentifier("file1"))) + XCTAssertTrue(newlyMaterialisedIds.contains(NSFileProviderItemIdentifier("dir1"))) // Verify that the database state is NOT updated let fileMetadata = dbManager.itemMetadata(ocId: "file1") @@ -77,7 +77,7 @@ final class MaterialisedEnumerationObserverTests: NextcloudFileProviderKitTestCa } func testMaterialisedObserverWithMixedState() async { - // Setup a DB with a mix of materialised and non-materialised items. + // Setup a DB with a mix of materialized and non-materialised items. var itemA = SendableItemMetadata(ocId: "itemA", fileName: "itemA", account: Self.account) itemA.downloaded = true // Was materialised @@ -101,19 +101,19 @@ final class MaterialisedEnumerationObserverTests: NextcloudFileProviderKitTestCa let expect = XCTestExpectation(description: "Enumerator completion handler called") let enumeratorItemsToReturn = [itemB, itemC] - let observer = MaterialisedEnumerationObserver(ncKitAccount: Self.account.ncKitAccount, dbManager: dbManager, log: FileProviderLogMock()) { newlyMaterialisedIds, unmaterialisedIds in - // Unmaterialised: itemA and dirD were materialised but not in the latest enumeration. + let observer = MaterializedEnumerationObserver(account: Self.account, dbManager: dbManager, log: FileProviderLogMock()) { newlyMaterialisedIds, unmaterialisedIds in + // Unmaterialised: itemA and dirD were materialized but not in the latest enumeration. XCTAssertEqual( unmaterialisedIds.count, 2, "itemA and dirD should be reported as unmaterialised." ) - XCTAssertTrue(unmaterialisedIds.contains("itemA")) - XCTAssertTrue(unmaterialisedIds.contains("dirD")) + XCTAssertTrue(unmaterialisedIds.contains(NSFileProviderItemIdentifier("itemA"))) + XCTAssertTrue(unmaterialisedIds.contains(NSFileProviderItemIdentifier("dirD"))) - // Newly Materialised: itemB was NOT materialised but WAS in the latest enumeration. + // Newly Materialised: itemB was NOT materialized but WAS in the latest enumeration. XCTAssertEqual( newlyMaterialisedIds.count, 1, "itemB should be reported as newly materialised." ) - XCTAssertEqual(newlyMaterialisedIds.first, "itemB") + XCTAssertEqual(newlyMaterialisedIds.first, NSFileProviderItemIdentifier("itemB")) // Check final database state let finalItemA = dbManager.itemMetadata(ocId: "itemA") diff --git a/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverTests.swift b/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverTests.swift index 1647ca50..1a92a8b5 100644 --- a/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverTests.swift +++ b/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverTests.swift @@ -166,14 +166,14 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { remoteInterface.capabilities = mockCapabilities // --- DB State (What the app thinks is true) --- - // A materialised file in the root that will be updated. + // A materialized file in the root that will be updated. var rootFileToUpdate = SendableItemMetadata(ocId: "rootFile", fileName: "root-file.txt", account: Self.account) rootFileToUpdate.downloaded = true rootFileToUpdate.etag = "ETAG_OLD_ROOTFILE" Self.dbManager.addItemMetadata(rootFileToUpdate) - // A materialised folder that will have its contents changed. + // A materialized folder that will have its contents changed. var folderA = SendableItemMetadata(ocId: "folderA", fileName: "FolderA", account: Self.account) folderA.directory = true @@ -181,7 +181,7 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { folderA.etag = "ETAG_OLD_FOLDERA" Self.dbManager.addItemMetadata(folderA) - // A materialised file inside FolderA that will be deleted. + // A materialized file inside FolderA that will be deleted. var fileInAToDelete = SendableItemMetadata(ocId: "fileInA", fileName: "file-in-a.txt", account: Self.account) fileInAToDelete.downloaded = true @@ -190,7 +190,7 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { fileInAToDelete.syncTime = Date(timeIntervalSince1970: 1000) Self.dbManager.addItemMetadata(fileInAToDelete) - // A materialised folder that will be deleted entirely. + // A materialized folder that will be deleted entirely. var folderBToDelete = SendableItemMetadata(ocId: "folderB", fileName: "FolderB", account: Self.account) folderBToDelete.directory = true @@ -356,7 +356,7 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { // No capabilities -> will force polling. remoteInterface.capabilities = "" - // DB State: A materialised file with an old ETag. + // DB State: A materialized file with an old ETag. var fileToUpdate = SendableItemMetadata(ocId: "item1", fileName: "file.txt", account: Self.account) fileToUpdate.downloaded = true fileToUpdate.etag = "ETAG_OLD"