From 974c0d4d0ad9ee58e187f4d60075399420ce15c0 Mon Sep 17 00:00:00 2001 From: Iva Horn Date: Wed, 24 Sep 2025 15:12:38 +0200 Subject: [PATCH 1/2] feat: Extended support for files_lock. - Restructured file lock information into a dedicated complex type. - Extended existing locking methods to support the lock type argument. - Updated the asynchronous lock method to resemble conventional patters of modern concurrency and error handling. Signed-off-by: Iva Horn --- .../NextcloudKit/Models/NKDataFileXML.swift | 23 +-- Sources/NextcloudKit/Models/NKFile.swift | 30 ++-- Sources/NextcloudKit/Models/NKLock.swift | 115 +++++++++++++++ Sources/NextcloudKit/NKLockType.swift | 27 ++++ .../NextcloudKit+Capabilities.swift | 50 ++++++- .../NextcloudKit/NextcloudKit+FilesLock.swift | 136 +++++++++++------- 6 files changed, 284 insertions(+), 97 deletions(-) create mode 100644 Sources/NextcloudKit/Models/NKLock.swift create mode 100644 Sources/NextcloudKit/NKLockType.swift diff --git a/Sources/NextcloudKit/Models/NKDataFileXML.swift b/Sources/NextcloudKit/Models/NKDataFileXML.swift index b0be505e..cb56d8fa 100644 --- a/Sources/NextcloudKit/Models/NKDataFileXML.swift +++ b/Sources/NextcloudKit/Models/NKDataFileXML.swift @@ -429,27 +429,8 @@ public class NKDataFileXML: NSObject { file.richWorkspace = richWorkspace } - if let lock = propstat["d:prop", "nc:lock"].int { - file.lock = NSNumber(integerLiteral: lock).boolValue - - if let lockOwner = propstat["d:prop", "nc:lock-owner"].text { - file.lockOwner = lockOwner - } - if let lockOwnerEditor = propstat["d:prop", "nc:lock-owner-editor"].text { - file.lockOwnerEditor = lockOwnerEditor - } - if let lockOwnerType = propstat["d:prop", "nc:lock-owner-type"].int { - file.lockOwnerType = lockOwnerType - } - if let lockOwnerDisplayName = propstat["d:prop", "nc:lock-owner-displayname"].text { - file.lockOwnerDisplayName = lockOwnerDisplayName - } - if let lockTime = propstat["d:prop", "nc:lock-time"].int { - file.lockTime = Date(timeIntervalSince1970: TimeInterval(lockTime)) - } - if let lockTimeOut = propstat["d:prop", "nc:lock-timeout"].int { - file.lockTimeOut = file.lockTime?.addingTimeInterval(TimeInterval(lockTimeOut)) - } + if let lock = NKLock(xml: propstat["d:prop"]) { + file.lock = lock } let tagsElements = propstat["d:prop", "nc:system-tags"] diff --git a/Sources/NextcloudKit/Models/NKFile.swift b/Sources/NextcloudKit/Models/NKFile.swift index 6e457157..c3eccde3 100644 --- a/Sources/NextcloudKit/Models/NKFile.swift +++ b/Sources/NextcloudKit/Models/NKFile.swift @@ -5,6 +5,9 @@ import Foundation +/// +/// A file or directory on the server. +/// public struct NKFile: Sendable { public var account: String public var classFile: String @@ -35,13 +38,12 @@ public struct NKFile: Sendable { public var ocId: String public var ownerId: String public var ownerDisplayName: String - public var lock: Bool - public var lockOwner: String - public var lockOwnerEditor: String - public var lockOwnerType: Int - public var lockOwnerDisplayName: String - public var lockTime: Date? - public var lockTimeOut: Date? + + /// + /// An optional lock on this file. `nil` equals the file not being locked. + /// + public var lock: NKLock? + public var path: String public var permissions: String public var quotaUsedBytes: Int64 @@ -107,13 +109,7 @@ public struct NKFile: Sendable { ocId: String = "", ownerId: String = "", ownerDisplayName: String = "", - lock: Bool = false, - lockOwner: String = "", - lockOwnerEditor: String = "", - lockOwnerType: Int = 0, - lockOwnerDisplayName: String = "", - lockTime: Date? = nil, - lockTimeOut: Date? = nil, + lock: NKLock? = nil, path: String = "", permissions: String = "", quotaUsedBytes: Int64 = 0, @@ -171,12 +167,6 @@ public struct NKFile: Sendable { self.ownerId = ownerId self.ownerDisplayName = ownerDisplayName self.lock = lock - self.lockOwner = lockOwner - self.lockOwnerEditor = lockOwnerEditor - self.lockOwnerType = lockOwnerType - self.lockOwnerDisplayName = lockOwnerDisplayName - self.lockTime = lockTime - self.lockTimeOut = lockTimeOut self.path = path self.permissions = permissions self.quotaUsedBytes = quotaUsedBytes diff --git a/Sources/NextcloudKit/Models/NKLock.swift b/Sources/NextcloudKit/Models/NKLock.swift new file mode 100644 index 00000000..9471d221 --- /dev/null +++ b/Sources/NextcloudKit/Models/NKLock.swift @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import SwiftyXMLParser + +/// +/// Description of a file lock. +/// +/// This is based on the description of the [`files_lock`](https://github.com/nextcloud/files_lock) server app. +/// +public struct NKLock: Equatable, Sendable { + /// + /// User id of the owning user. + /// + public let owner: String + + /// + /// App id of an app owned lock to allow clients to suggest joining the collaborative editing session through the web or direct editing. + /// + public let ownerEditor: String + + /// + /// What kind of lock this is. + /// + public let ownerType: NKLockType + + /// + /// Display name of the lock owner. + /// + public let ownerDisplayName: String + + /// + /// Timestamp of the lock creation time. + /// + public let time: Date? + + /// + /// TTL of the lock in seconds staring from the creation time. + /// A value of 0 means the timeout is infinite. + /// Client implementations should properly handle this specific value. + /// + public let timeOut: Date? + + /// + /// Unique lock token (to be preserved on the client side while holding the lock to sent once full webdav locking is implemented). + /// + public let token: String? + + /// + /// Initialize from a SwiftyXML accessor. + /// + /// This is intended for creating an instance based on a superset of required properties returned by a `PROPFIND` request to the server about an item. + /// + public init?(xml properties: XML.Accessor) { + guard let isLocked = properties["nc:lock"].bool else { + return nil + } + + guard let owner = properties["nc:lock-owner"].text else { + return nil + } + + guard let ownerDisplayName = properties["nc:lock-owner-displayname"].text else { + return nil + } + + guard let ownerEditor = properties["nc:lock-owner-editor"].text else { + return nil + } + + guard let rawOwnerTypeValue = properties["nc:lock-owner-type"].int, let lockOwnerType = NKLockType(rawValue: rawOwnerTypeValue) else { + return nil + } + + guard let rawTime = properties["nc:lock-time"].double else { + return nil + } + + guard let rawTimeOut = properties["nc:lock-timeout"].double else { + return nil + } + + let lockToken = properties["nc:lock-token"].text + + self.owner = owner + self.ownerEditor = ownerEditor + self.ownerType = lockOwnerType + self.ownerDisplayName = ownerDisplayName + self.time = Date(timeIntervalSince1970: rawTime) + self.timeOut = Date(timeIntervalSince1970: rawTime + rawTimeOut) + self.token = lockToken + } + + /// + /// Initialize from the response body data of an WebDAV request. + /// + public init?(data: Data) { + let properties = XML.parse(data)["d:prop"] + self.init(xml: properties) + } + + /// + /// Initialize from raw values. + /// + public init(owner: String, ownerEditor: String, ownerType: NKLockType, ownerDisplayName: String, time: Date?, timeOut: Date?, token: String?) { + self.owner = owner + self.ownerEditor = ownerEditor + self.ownerType = ownerType + self.ownerDisplayName = ownerDisplayName + self.time = time + self.timeOut = timeOut + self.token = token + } +} diff --git a/Sources/NextcloudKit/NKLockType.swift b/Sources/NextcloudKit/NKLockType.swift new file mode 100644 index 00000000..af0456d2 --- /dev/null +++ b/Sources/NextcloudKit/NKLockType.swift @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +/// +/// The [`files_lock`](https://github.com/nextcloud/files_lock) server apps distinguishes between different lock types which are represented by this type. +/// +public enum NKLockType: Int, Sendable { + /// + /// This lock type is initiated by a user manually through the WebUI or Clients and will limit editing capabilities on the file to the lock owning user. + /// + case user = 0 + + /// + /// This lock type is created by collaborative apps like Text or Office to avoid outside changes through WebDAV or other apps. + /// + case app = 1 + + /// + /// This lock type will bind the ownership to the provided lock token. + /// Any request that aims to modify the file will be required to sent the token, the user itself is not able to write to files without the token. + /// This will allow to limit the locking to an individual client. + /// + /// This is mostly used for automatic client locking, e.g. when a file is opened in a client or with WebDAV clients that support native WebDAV locking. + /// The lock token can be skipped on follow up requests using the OCS API or the X-User-Lock header for WebDAV requests, but in that case the server will not be able to validate the lock ownership when unlocking the file from the client. + /// + case token = 2 +} diff --git a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift index d0d4b5ec..1fcab002 100644 --- a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift +++ b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift @@ -16,12 +16,15 @@ import Alamofire public extension NextcloudKit { + /// /// Retrieves the capabilities of the Nextcloud server for the given account. + /// /// - Parameters: /// - account: The account identifier. /// - options: Additional request options. /// - taskHandler: Callback for the underlying URL session task. /// - completion: Callback returning parsed capabilities or an error. + /// func getCapabilities(account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, @@ -269,6 +272,15 @@ public extension NextcloudKit { struct Files: Codable { let undelete: Bool? + + /// + /// Whether different lock types as defined in ``NKLockType`` are supported or not. + /// + let lockTypes: Bool? + + /// + /// The version of the locking API. + /// let locking: String? let comments: Bool? let versioning: Bool? @@ -282,6 +294,7 @@ public extension NextcloudKit { let forbiddenFileNameExtensions: [String]? enum CodingKeys: String, CodingKey { + case lockTypes = "api-feature-lock-type" case undelete, locking, comments, versioning, directEditing, bigfilechunking case versiondeletion = "version_deletion" case versionlabeling = "version_labeling" @@ -389,6 +402,7 @@ public extension NextcloudKit { capabilities.notification = json.notifications?.ocsendpoints ?? [] capabilities.filesUndelete = json.files?.undelete ?? false + capabilities.filesLockTypes = json.files?.lockTypes ?? false capabilities.filesLockVersion = json.files?.locking ?? "" capabilities.filesComments = json.files?.comments ?? false capabilities.filesBigfilechunking = json.files?.bigfilechunking ?? false @@ -440,12 +454,17 @@ actor CapabilitiesStore { } } -/// Singleton container and public API for accessing and caching capabilities. +/// +/// Singleton container and public API for accessing and caching capabilities for user accounts. +/// final public class NKCapabilities: Sendable { public static let shared = NKCapabilities() private let store = CapabilitiesStore() + /// + /// Flattened set of capabilities after parsing the server response. + /// public class Capabilities: @unchecked Sendable { public var serverVersionMajor: Int = 0 public var serverVersion: String = "" @@ -472,6 +491,15 @@ final public class NKCapabilities: Sendable { public var activity: [String] = [] public var notification: [String] = [] public var filesUndelete: Bool = false + + /// + /// Whether different lock types as defined in ``NKLockType`` are supported or not. + /// + public var filesLockTypes: Bool = false + + /// + /// The version of the locking API. + /// public var filesLockVersion: String = "" // NC 24 public var filesComments: Bool = false // NC 20 public var filesBigfilechunking: Bool = false @@ -499,17 +527,37 @@ final public class NKCapabilities: Sendable { // MARK: - Public API + /// + /// Set or overwrite the existing capabilities in the store. + /// + /// - Parameters: + /// - account: The account identifier for which the capabilities should be stored for. + /// - capabilities: The actual capabilities which should be stored. + /// public func setCapabilities(for account: String, capabilities: Capabilities) async { await store.set(account, value: capabilities) } + /// + /// The capabilities by the given account identifier. + /// + /// - Parameter account: The account identifier for which the capabilities should be returned. + /// + /// - Returns: Either the acquired capabilities or a default object. + /// public func getCapabilities(for account: String?) async -> Capabilities { guard let account else { return Capabilities() } + return await store.get(account) ?? Capabilities() } + /// + /// Remove capabilities stored in the in-memory cache. + /// + /// - Parameter account: The account identifier for which the capabilities should be removed. + /// public func removeCapabilities(for account: String) async { await store.remove(account) } diff --git a/Sources/NextcloudKit/NextcloudKit+FilesLock.swift b/Sources/NextcloudKit/NextcloudKit+FilesLock.swift index 215d4e96..839646da 100644 --- a/Sources/NextcloudKit/NextcloudKit+FilesLock.swift +++ b/Sources/NextcloudKit/NextcloudKit+FilesLock.swift @@ -7,77 +7,103 @@ import Alamofire import SwiftyJSON public extension NextcloudKit { - /// Sends a WebDAV LOCK or UNLOCK request for a file on the server, - /// depending on the `shouldLock` flag. This is used to prevent or release - /// concurrent edits on a file. + /// + /// Sends a WebDAV `LOCK` or `UNLOCK` request for a file on the server, depending on the `shouldLock` flag. + /// This is used to prevent or release concurrent edits on a file. + /// + /// > Structured Concurrency: Use ``lockUnlockFile(serverUrlFileName:type:shouldLock:account:options:taskHandler:)`` for an `async` implementation which `throws`. /// /// Parameters: - /// - serverUrlFileName: Fully qualified and encoded URL of the file to lock/unlock. - /// - shouldLock: Pass `true` to lock the file, `false` to unlock it. - /// - account: The Nextcloud account performing the operation. - /// - options: Optional request options (e.g. headers, queue). - /// - taskHandler: Closure to access the URLSessionTask. - /// - completion: Completion handler returning the account, response, and NKError. + /// - serverUrlFileName: Fully qualified and encoded URL of the file to lock/unlock. + /// - type: Optionally, the type of the lock as supported by the server. + /// - shouldLock: Pass `true` to lock the file, `false` to unlock it. + /// - account: The Nextcloud account performing the operation. + /// - options: Optional request options (e.g. headers, queue). + /// - taskHandler: Closure to access the URLSessionTask. + /// - completion: Completion handler returning the account, response, and NKError. + /// func lockUnlockFile(serverUrlFileName: String, + type: NKLockType? = nil, shouldLock: Bool, account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, completion: @escaping (_ account: String, _ responseData: AFDataResponse?, _ error: NKError) -> Void) { - guard let url = serverUrlFileName.encodedToUrl - else { - return options.queue.async { completion(account, nil, .urlError) } - } - let method = HTTPMethod(rawValue: shouldLock ? "LOCK" : "UNLOCK") - guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), - var headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { - return options.queue.async { completion(account, nil, .urlError) } - } - headers.update(name: "X-User-Lock", value: "1") + Task { + guard let url = serverUrlFileName.encodedToUrl else { + return options.queue.async { + completion(account, nil, .urlError) + } + } + + let method = HTTPMethod(rawValue: shouldLock ? "LOCK" : "UNLOCK") + + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + var headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { + completion(account, nil, .urlError) + } + } + + headers.update(name: "X-User-Lock", value: "1") + let capabilities = await NKCapabilities.shared.getCapabilities(for: account) - nkSession.sessionData.request(url, method: method, encoding: URLEncoding.default, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in - task.taskDescription = options.taskDescription - taskHandler(task) - }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in - switch response.result { - case .failure(let error): - let error = NKError(error: error, afResponse: response, responseData: response.data) - options.queue.async { completion(account, response, error) } - case .success: - options.queue.async { completion(account, response, .success) } + if capabilities.filesLockTypes, let type { + headers.update(name: "X-User-Lock-Type", value: String(type.rawValue)) + } + + nkSession + .sessionData + .request(url, method: method, encoding: URLEncoding.default, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + + options.queue.async { + completion(account, response, error) + } + case .success: + options.queue.async { + completion(account, response, .success) + } + } } } } + /// /// Asynchronously locks or unlocks a file on the server via WebDAV. + /// /// - Parameters: - /// - serverUrlFileName: The server-side full URL of the file to lock or unlock. - /// - shouldLock: `true` to lock the file, `false` to unlock it. - /// - account: The Nextcloud account performing the action. - /// - options: Optional request configuration (headers, queue, etc.). - /// - taskHandler: Optional monitoring of the `URLSessionTask`. + /// - serverUrlFileName: The server-side full URL of the file to lock or unlock. + /// - shouldLock: `true` to lock the file, `false` to unlock it. + /// - account: The Nextcloud account performing the action. + /// - options: Optional request configuration (headers, queue, etc.). + /// - taskHandler: Optional monitoring of the `URLSessionTask`. + /// /// - Returns: A tuple containing the account, the server response, and any error encountered. - func lockUnlockFileAsync(serverUrlFileName: String, - shouldLock: Bool, - account: String, - options: NKRequestOptions = NKRequestOptions(), - taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } - ) async -> ( - account: String, - responseData: AFDataResponse?, - error: NKError - ) { - await withCheckedContinuation { continuation in - lockUnlockFile(serverUrlFileName: serverUrlFileName, - shouldLock: shouldLock, - account: account, - options: options, - taskHandler: taskHandler) { account, responseData, error in - continuation.resume(returning: ( - account: account, - responseData: responseData, - error: error - )) + /// + func lockUnlockFile(serverUrlFileName: String, type: NKLockType? = nil, shouldLock: Bool, account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }) async throws -> NKLock? { + try await withCheckedThrowingContinuation { continuation in + lockUnlockFile(serverUrlFileName: serverUrlFileName, type: type, shouldLock: shouldLock, account: account, options: options, taskHandler: taskHandler) { _, responseData, error in + switch error { + case .success: + var lock: NKLock? + + if let data = responseData?.data, let lock = NKLock(data: data) { + continuation.resume(returning: lock) + return + } + + continuation.resume(returning: nil) + default: + continuation.resume(throwing: error) + } } } } From 525bafedbb6ed37435e7533ae4e5db6e2f67a9ce Mon Sep 17 00:00:00 2001 From: Iva Horn Date: Fri, 26 Sep 2025 10:00:45 +0200 Subject: [PATCH 2/2] fix: Remove unnecessary UIKit imports to fix macOS builds. Signed-off-by: Iva Horn --- Tests/NextcloudKitIntegrationTests/Common/BaseXCTestCase.swift | 1 - Tests/NextcloudKitIntegrationTests/Common/TestConstants.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/Tests/NextcloudKitIntegrationTests/Common/BaseXCTestCase.swift b/Tests/NextcloudKitIntegrationTests/Common/BaseXCTestCase.swift index 158b3720..3e7d4550 100644 --- a/Tests/NextcloudKitIntegrationTests/Common/BaseXCTestCase.swift +++ b/Tests/NextcloudKitIntegrationTests/Common/BaseXCTestCase.swift @@ -4,7 +4,6 @@ import XCTest import Foundation -import UIKit import Alamofire import NextcloudKit diff --git a/Tests/NextcloudKitIntegrationTests/Common/TestConstants.swift b/Tests/NextcloudKitIntegrationTests/Common/TestConstants.swift index 13baf802..fccf0596 100644 --- a/Tests/NextcloudKitIntegrationTests/Common/TestConstants.swift +++ b/Tests/NextcloudKitIntegrationTests/Common/TestConstants.swift @@ -3,7 +3,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later import Foundation -import UIKit public class TestConstants { static let timeoutLong: Double = 400