From 68623f5d3b4c380796399530062c112562261c4b Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 10 Feb 2026 14:36:44 +0100 Subject: [PATCH 1/3] NKOperationHandle Signed-off-by: Marino Faggiana --- Sources/NextcloudKit/NKOperationHandle.swift | 44 +++++++++++++++++++ .../NextcloudKit/NextcloudKit+Search.swift | 35 +++++++++++---- 2 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 Sources/NextcloudKit/NKOperationHandle.swift diff --git a/Sources/NextcloudKit/NKOperationHandle.swift b/Sources/NextcloudKit/NKOperationHandle.swift new file mode 100644 index 00000000..a02fb4e9 --- /dev/null +++ b/Sources/NextcloudKit/NKOperationHandle.swift @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Alamofire + +/// An operation handle that exposes the underlying Alamofire DataRequest and URLSessionTask +/// as soon as they are created, allowing clients to cancel an in-flight operation or observe +/// its lifecycle in a thread-safe way. +/// +/// NKOperationHandle is an actor, so interactions are serialized and safe across concurrency domains. +/// Typical usage: +/// - Pass an instance to APIs that create a network request. +/// - The API will call `set(request:)` and/or `set(task:)` when the request/task is created. +/// - Call `cancel()` at any time to stop the operation. +/// - Optionally call `clear()` to drop stored references. +/// +/// Notes: +/// - `cancel()` prefers canceling the Alamofire DataRequest when available; otherwise it falls back +/// to canceling the underlying URLSessionTask. +/// - `clear()` is optional and can be used to explicitly release references once an operation completes. +public actor NKOperationHandle { + private(set) var request: DataRequest? + private(set) var task: URLSessionTask? + + public init() {} + + public func set(request: DataRequest) { self.request = request } + public func set(task: URLSessionTask) { self.task = task } + + public func cancel() { + if let request = request { + request.cancel() + } else { + task?.cancel() + } + } + + public func clear() { + request = nil + task = nil + } +} diff --git a/Sources/NextcloudKit/NextcloudKit+Search.swift b/Sources/NextcloudKit/NextcloudKit+Search.swift index c8f5124a..04da6581 100644 --- a/Sources/NextcloudKit/NextcloudKit+Search.swift +++ b/Sources/NextcloudKit/NextcloudKit+Search.swift @@ -7,21 +7,25 @@ import Alamofire import SwiftyJSON public extension NextcloudKit { + + /// Performs a unified search using multiple providers and returns results asynchronously. /// /// - Parameters: /// - timeout: The individual request timeout per provider. /// - account: The Nextcloud account performing the search. /// - options: Optional configuration for the request (headers, queue, etc.). + /// - handle: Optional operation handle that receives the underlying DataRequest and URLSessionTask + /// as soon as they are created. Use it to cancel the request while it’s in flight + /// (via handle.cancel()) or to observe the task/request lifecycle. /// - filter: A closure to filter which `NKSearchProvider` are enabled. - /// - taskHandler: Callback triggered when a `URLSessionTask` is created. /// /// - Returns: NKSearchProvider, NKError func unifiedSearchProviders(timeout: TimeInterval = 30, account: String, options: NKRequestOptions = NKRequestOptions(), - filter: @escaping (NKSearchProvider) -> Bool = { _ in true }, - taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + handle: NKOperationHandle? = nil, + filter: @escaping (NKSearchProvider) -> Bool = { _ in true } ) async -> (providers: [NKSearchProvider]?, error: NKError) { let endpoint = "ocs/v2.php/search/providers" guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), @@ -32,11 +36,17 @@ public extension NextcloudKit { let request = nkSession.sessionData .request(url, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) - .validate(statusCode: 200..<300) .onURLSessionTaskCreation { task in task.taskDescription = options.taskDescription - taskHandler(task) + Task { + if let handle { + await handle.set(task: task) + } + } } + .validate(statusCode: 200..<300) + + await handle?.set(request: request) let response = await request.serializingData().response switch response.result { @@ -63,7 +73,9 @@ public extension NextcloudKit { /// - timeout: The timeout interval for the search request. /// - account: The Nextcloud account performing the search. /// - options: Optional request configuration such as headers and queue. - /// - taskHandler: Callback to observe the underlying URLSessionTask. + /// - handle: Optional operation handle that receives the underlying DataRequest and URLSessionTask + /// as soon as they are created. Use it to cancel the request while it’s in flight + /// (via handle.cancel()) or to observe the task/request lifecycle. /// /// - Returns: NKSearchResult, NKError func unifiedSearch(providerId: String, @@ -73,7 +85,7 @@ public extension NextcloudKit { timeout: TimeInterval = 60, account: String, options: NKRequestOptions = NKRequestOptions(), - taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }) + handle: NKOperationHandle? = nil) async -> (searchResult: NKSearchResult?, error: NKError) { guard let term = term.urlEncoded, let nkSession = nkCommonInstance.nksessions.session(forAccount: account), @@ -105,8 +117,14 @@ public extension NextcloudKit { .validate(statusCode: 200..<300) .onURLSessionTaskCreation { task in task.taskDescription = options.taskDescription - taskHandler(task) + Task { + if let handle { + await handle.set(task: task) + } + } } + + await handle?.set(request: request) let response = await request.serializingData().response switch response.result { @@ -211,3 +229,4 @@ public class NKSearchProvider: NSObject { return allProvider.compactMap(NKSearchProvider.init) } } + From d2e4f5363510a3c5ef706e361c7ed18bdd6d8e1e Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 10 Feb 2026 14:52:20 +0100 Subject: [PATCH 2/3] NKOperationHandle Signed-off-by: Marino Faggiana --- Sources/NextcloudKit/NKOperationHandle.swift | 86 +++++++++++++++++--- 1 file changed, 75 insertions(+), 11 deletions(-) diff --git a/Sources/NextcloudKit/NKOperationHandle.swift b/Sources/NextcloudKit/NKOperationHandle.swift index a02fb4e9..4e187b2e 100644 --- a/Sources/NextcloudKit/NKOperationHandle.swift +++ b/Sources/NextcloudKit/NKOperationHandle.swift @@ -6,28 +6,82 @@ import Foundation import Alamofire /// An operation handle that exposes the underlying Alamofire DataRequest and URLSessionTask -/// as soon as they are created, allowing clients to cancel an in-flight operation or observe -/// its lifecycle in a thread-safe way. +/// as soon as they are created, allowing clients to cancel an in-flight operation, observe +/// its lifecycle, and react to state changes via an async events stream. /// +/// Concurrency & thread-safety: /// NKOperationHandle is an actor, so interactions are serialized and safe across concurrency domains. +/// +/// Features: +/// - Store and expose the underlying `DataRequest` and `URLSessionTask`. +/// - Cancel the operation at any time using `cancel()` (prefers `DataRequest.cancel()`; falls back to `URLSessionTask.cancel()`). +/// - Observe lifecycle events with `events()` returning `AsyncStream`. +/// Emitted events include: +/// - `.didSetRequest(DataRequest)` when the request is created and stored +/// - `.didSetTask(URLSessionTask)` when the task is created and stored +/// - `.didCancel` after `cancel()` is invoked +/// - `.didClear` when references are cleared via `clear()` +/// - Check whether an operation is currently active using `isActive()`. +/// - Explicitly release stored references using `clear()`. +/// /// Typical usage: -/// - Pass an instance to APIs that create a network request. -/// - The API will call `set(request:)` and/or `set(task:)` when the request/task is created. -/// - Call `cancel()` at any time to stop the operation. -/// - Optionally call `clear()` to drop stored references. +/// ```swift +/// let handle = NKOperationHandle() +/// Task { +/// for await event in await handle.events() { +/// switch event { +/// case .didSetTask(let task): +/// print("Task available:", task) +/// case .didSetRequest(let request): +/// print("Request available:", request) +/// case .didCancel: +/// print("Operation cancelled") +/// case .didClear: +/// print("Handle cleared") +/// } +/// } +/// } +/// // Pass `handle` to an API that creates a network request. +/// // The API will call `set(request:)` and/or `set(task:)` when available. +/// // You can cancel at any time: +/// await handle.cancel() +/// ``` /// /// Notes: -/// - `cancel()` prefers canceling the Alamofire DataRequest when available; otherwise it falls back -/// to canceling the underlying URLSessionTask. -/// - `clear()` is optional and can be used to explicitly release references once an operation completes. +/// - The events stream is created lazily the first time `events()` is called and is finished in `clear()`. +/// - If you don't need event observation, you can ignore `events()` and use only `cancel()`/`isActive()`. public actor NKOperationHandle { private(set) var request: DataRequest? private(set) var task: URLSessionTask? + public enum NKOperationEvent { + case didSetRequest(DataRequest) + case didSetTask(URLSessionTask) + case didCancel + case didClear + } + + private var eventsStream: AsyncStream? + private var eventsContinuation: AsyncStream.Continuation? + public init() {} - public func set(request: DataRequest) { self.request = request } - public func set(task: URLSessionTask) { self.task = task } + public func events() -> AsyncStream { + if let eventsStream { return eventsStream } + let (stream, continuation) = AsyncStream.makeStream() + self.eventsStream = stream + self.eventsContinuation = continuation + return stream + } + + public func set(request: DataRequest) { + self.request = request + eventsContinuation?.yield(.didSetRequest(request)) + } + public func set(task: URLSessionTask) { + self.task = task + eventsContinuation?.yield(.didSetTask(task)) + } public func cancel() { if let request = request { @@ -35,10 +89,20 @@ public actor NKOperationHandle { } else { task?.cancel() } + eventsContinuation?.yield(.didCancel) } public func clear() { + eventsContinuation?.yield(.didClear) request = nil task = nil + eventsContinuation?.finish() + eventsContinuation = nil + eventsStream = nil + } + + public func isActive() -> Bool { + return request != nil || task != nil } } + From 397e6efa501a2f2f3cb74b8130a7b97f95ec0180 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 10 Feb 2026 15:08:58 +0100 Subject: [PATCH 3/3] cod Signed-off-by: Marino Faggiana --- Sources/NextcloudKit/NKOperationHandle.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/NextcloudKit/NKOperationHandle.swift b/Sources/NextcloudKit/NKOperationHandle.swift index 4e187b2e..2285950b 100644 --- a/Sources/NextcloudKit/NKOperationHandle.swift +++ b/Sources/NextcloudKit/NKOperationHandle.swift @@ -82,6 +82,12 @@ public actor NKOperationHandle { self.task = task eventsContinuation?.yield(.didSetTask(task)) } + public func currentRequest() -> DataRequest? { + request + } + public func currentTask() -> URLSessionTask? { + task + } public func cancel() { if let request = request {