Skip to content
Open
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
204 changes: 204 additions & 0 deletions Sources/Storage/DownloadSessionDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
//
// DownloadSessionDelegate.swift
// Storage
//
// Created by Guilherme Souza on 04/05/26.
//

import ConcurrencyExtras
import Foundation

#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, Sendable {

struct DownloadTaskState {
let eventsContinuation: AsyncStream<TransferEvent<URL>>.Continuation
let resultContinuation: AsyncStream<Result<URL, any Error>>.Continuation
}

struct MutableState {
var tasks: [Int: DownloadTaskState] = [:]
var backgroundCompletionHandler: (@Sendable () -> Void)?
}

private let state = LockIsolated(MutableState())

// MARK: - Task creation

/// Creates a `StorageDownloadTask` backed by this delegate.
///
/// `buildRequest` is called asynchronously before the underlying
/// `URLSessionDownloadTask` is created, so callers can fetch an auth token
/// (via `_HTTPClient.createRequest`) without blocking.
func makeStorageDownloadTask(
in session: URLSession,
buildRequest: @escaping @Sendable () async throws -> URLRequest
) -> StorageDownloadTask {
let (eventStream, eventsContinuation) = AsyncStream<TransferEvent<URL>>.makeStream()
let (resultStream, resultContinuation) = AsyncStream<Result<URL, any Error>>.makeStream(
bufferingPolicy: .bufferingNewest(1))

let urlTaskRef = LockIsolated<URLSessionDownloadTask?>(nil)

let resultTask = Task<URL, any Error> {
for await r in resultStream { return try r.get() }
throw StorageError.cancelled
}

// Bootstrap task: fetch the token, build the request, then start the download.
let bootstrapTask = Task {
do {
let request = try await buildRequest()
let urlTask = session.downloadTask(with: request)

state.withValue {
$0.tasks[urlTask.taskIdentifier] = DownloadTaskState(
eventsContinuation: eventsContinuation,
resultContinuation: resultContinuation
)
}

urlTaskRef.setValue(urlTask)
urlTask.resume()
} catch {
let storageError = StorageError.from(error)
eventsContinuation.yield(.failed(storageError))
eventsContinuation.finish()
resultContinuation.yield(.failure(storageError))
resultContinuation.finish()
}
}

eventsContinuation.onTermination = { [urlTaskRef] reason in
guard case .cancelled = reason else { return }
urlTaskRef.value?.cancel()
bootstrapTask.cancel()
}

return StorageDownloadTask(
events: eventStream,
resultTask: resultTask,
pause: { urlTaskRef.value?.suspend() },
resume: { urlTaskRef.value?.resume() },
cancel: {
bootstrapTask.cancel()
urlTaskRef.value?.cancel()
// Finish continuations in case the bootstrap was cancelled before the
// URLSessionDownloadTask existed — otherwise observers hang forever.
eventsContinuation.finish()
resultContinuation.finish()
}
)
}

/// Package-level access for tests to drive delegate callbacks directly.
package func makeDownloadTask(
in session: URLSession,
request: URLRequest
) -> (
stream: AsyncStream<TransferEvent<URL>>,
eventsContinuation: AsyncStream<TransferEvent<URL>>.Continuation,
task: URLSessionDownloadTask
) {
let (eventStream, eventsContinuation) = AsyncStream<TransferEvent<URL>>.makeStream()
let (resultStream, resultContinuation) = AsyncStream<Result<URL, any Error>>.makeStream(
bufferingPolicy: .bufferingNewest(1))
let urlTask = session.downloadTask(with: request)
state.withValue {
$0.tasks[urlTask.taskIdentifier] = DownloadTaskState(
eventsContinuation: eventsContinuation,
resultContinuation: resultContinuation
)
}
_ = resultStream // satisfy unused warning — test uses stream directly via delegate callbacks
_ = resultContinuation
return (eventStream, eventsContinuation, urlTask)
}

func setBackgroundCompletionHandler(_ handler: @escaping @Sendable () -> Void) {
state.withValue { $0.backgroundCompletionHandler = handler }
}

// MARK: - URLSessionDownloadDelegate

func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
state.withValue {
guard let taskState = $0.tasks[downloadTask.taskIdentifier] else { return }
taskState.eventsContinuation.yield(
.progress(
TransferProgress(
bytesTransferred: totalBytesWritten,
totalBytes: totalBytesExpectedToWrite
)))
}
}

func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
state.withValue {
guard let taskState = $0.tasks[downloadTask.taskIdentifier] else { return }

let destination = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)

do {
try FileManager.default.moveItem(at: location, to: destination)
taskState.eventsContinuation.yield(.completed(destination))
taskState.eventsContinuation.finish()
taskState.resultContinuation.yield(.success(destination))
taskState.resultContinuation.finish()
} catch {
let storageError = StorageError.fileSystemError(underlying: error)
taskState.eventsContinuation.yield(.failed(storageError))
taskState.eventsContinuation.finish()
taskState.resultContinuation.yield(.failure(storageError))
taskState.resultContinuation.finish()
}
$0.tasks.removeValue(forKey: downloadTask.taskIdentifier)
}
}

func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: (any Error)?
) {
guard let error else { return }

state.withValue {
guard let taskState = $0.tasks[task.taskIdentifier] else { return }

let storageError: StorageError
if (error as? URLError)?.code == .cancelled {
storageError = .cancelled
} else {
storageError = .networkError(underlying: error)
}

taskState.eventsContinuation.yield(.failed(storageError))
taskState.eventsContinuation.finish()
taskState.resultContinuation.yield(.failure(storageError))
taskState.resultContinuation.finish()
$0.tasks.removeValue(forKey: task.taskIdentifier)
}
}

func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
state.withValue {
$0.backgroundCompletionHandler?()
$0.backgroundCompletionHandler = nil
}
}
}
50 changes: 50 additions & 0 deletions Sources/Storage/StorageClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ public struct StorageClientConfiguration: Sendable {
/// Defaults to `false`.
public let useNewHostname: Bool

/// When set, downloads use `URLSessionConfiguration.background(withIdentifier:)`,
/// allowing transfers to continue while the app is suspended.
///
/// Requires wiring `handleBackgroundEvents(forSessionIdentifier:completionHandler:)` in
/// your `AppDelegate`.
///
/// When `nil` (the default), a standard foreground session is used.
public var backgroundDownloadSessionIdentifier: String?

/// The TUS upload chunk size in bytes.
///
/// Files uploaded via the TUS resumable protocol are split into chunks of this size.
Expand All @@ -69,19 +78,23 @@ public struct StorageClientConfiguration: Sendable {
/// - logger: An optional `SupabaseLogger` for request/response diagnostics. Defaults to `nil`.
/// - useNewHostname: When `true`, rewrites the host to the dedicated storage subdomain for
/// large-file upload support. Defaults to `false`.
/// - backgroundDownloadSessionIdentifier: When set, downloads use a background
/// `URLSessionConfiguration` with this identifier. Defaults to `nil`.
/// - tusChunkSize: TUS upload chunk size in bytes. Also used as the threshold for the smart
/// default `upload()`/`update()` methods. Defaults to 6 MB.
public init(
headers: [String: String],
session: URLSession = URLSession(configuration: .default),
logger: (any SupabaseLogger)? = nil,
useNewHostname: Bool = false,
backgroundDownloadSessionIdentifier: String? = nil,
tusChunkSize: Int = 6 * 1024 * 1024
) {
self.headers = headers
self.session = session
self.logger = logger
self.useNewHostname = useNewHostname
self.backgroundDownloadSessionIdentifier = backgroundDownloadSessionIdentifier
self.tusChunkSize = tusChunkSize
}
}
Expand Down Expand Up @@ -127,6 +140,9 @@ public final class StorageClient: Sendable {
package let http: _HTTPClient
private let usesTokenProvider: Bool

let downloadDelegate: DownloadSessionDelegate
let downloadSession: URLSession

let encoder: JSONEncoder = {
let encoder = JSONEncoder.supabase()
encoder.keyEncodingStrategy = .convertToSnakeCase
Expand Down Expand Up @@ -223,6 +239,28 @@ public final class StorageClient: Sendable {
tokenProvider: tokenProvider
)

let downloadDelegate = DownloadSessionDelegate()
self.downloadDelegate = downloadDelegate

#if canImport(Darwin)
let downloadSessionConfig: URLSessionConfiguration =
configuration.backgroundDownloadSessionIdentifier.map {
.background(withIdentifier: $0)
} ?? .default
#else
let downloadSessionConfig: URLSessionConfiguration = .default
#endif
// Propagate any custom protocol classes (e.g. for testing) from the HTTP session.
if let protocolClasses = configuration.session.configuration.protocolClasses,
!protocolClasses.isEmpty
{
downloadSessionConfig.protocolClasses = protocolClasses
}
self.downloadSession = URLSession(
configuration: downloadSessionConfig,
delegate: downloadDelegate,
delegateQueue: nil
)
}

func mergedHeaders(_ headers: [String: String]? = nil) -> [String: String] {
Expand Down Expand Up @@ -350,6 +388,18 @@ public final class StorageClient: Sendable {
configuration.logger?.error("Response: Failure \(error)")
}

/// Forward background URLSession events from your `AppDelegate` to the Storage client.
///
/// Call this from `application(_:handleEventsForBackgroundURLSession:completionHandler:)`
/// when the `identifier` matches the one configured in ``StorageClientConfiguration/backgroundDownloadSessionIdentifier``.
public func handleBackgroundEvents(
forSessionIdentifier identifier: String,
completionHandler: @escaping @Sendable () -> Void
) {
guard identifier == configuration.backgroundDownloadSessionIdentifier else { return }
downloadDelegate.setBackgroundCompletionHandler(completionHandler)
}

/// Returns a ``StorageFileAPI`` scoped to the given bucket.
///
/// All file operations — upload, download, list, delete, signed URLs — are performed through
Expand Down
Loading
Loading