Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions Sources/NextcloudKit/NKOperationHandle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// 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, 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<NKOperationEvent>`.
/// 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:
/// ```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:
/// - 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<NKOperationEvent>?
private var eventsContinuation: AsyncStream<NKOperationEvent>.Continuation?

public init() {}

public func events() -> AsyncStream<NKOperationEvent> {
if let eventsStream { return eventsStream }
let (stream, continuation) = AsyncStream<NKOperationEvent>.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 currentRequest() -> DataRequest? {
request
}
public func currentTask() -> URLSessionTask? {
task
}

public func cancel() {
if let request = request {
request.cancel()
} 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
}
}

35 changes: 27 additions & 8 deletions Sources/NextcloudKit/NextcloudKit+Search.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -211,3 +229,4 @@ public class NKSearchProvider: NSObject {
return allProvider.compactMap(NKSearchProvider.init)
}
}

Loading