Skip to content

Commit d7dfd51

Browse files
grdsdevclaude
andcommitted
feat(storage): add DownloadSessionDelegate and download API with pause/resume/cancel
- Add DownloadSessionDelegate actor routing URLSessionDownloadDelegate callbacks by taskIdentifier, enabling background URLSession support - Add download(path:) -> StorageDownloadTask — downloads to disk with pause/resume/cancel via URLSessionDownloadTask - Add downloadData(path:) -> StorageTransferTask<Data> — convenience wrapper that loads into memory - Add backgroundDownloadSessionIdentifier config for background transfers - Add StorageClient.handleBackgroundEvents(forSessionIdentifier:completionHandler:) for AppDelegate wiring Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent a05b1ee commit d7dfd51

6 files changed

Lines changed: 623 additions & 0 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
//
2+
// DownloadSessionDelegate.swift
3+
// Storage
4+
//
5+
// Created by Guilherme Souza on 04/05/26.
6+
//
7+
8+
import ConcurrencyExtras
9+
import Foundation
10+
11+
#if canImport(FoundationNetworking)
12+
import FoundationNetworking
13+
#endif
14+
15+
final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, Sendable {
16+
17+
struct DownloadTaskState {
18+
let eventsContinuation: AsyncStream<TransferEvent<URL>>.Continuation
19+
let resultContinuation: AsyncStream<Result<URL, any Error>>.Continuation
20+
}
21+
22+
struct MutableState {
23+
var tasks: [Int: DownloadTaskState] = [:]
24+
var backgroundCompletionHandler: (@Sendable () -> Void)?
25+
}
26+
27+
private let state = LockIsolated(MutableState())
28+
29+
// MARK: - Task creation
30+
31+
/// Creates a `StorageDownloadTask` backed by this delegate.
32+
///
33+
/// `buildRequest` is called asynchronously before the underlying
34+
/// `URLSessionDownloadTask` is created, so callers can fetch an auth token
35+
/// (via `_HTTPClient.createRequest`) without blocking.
36+
func makeStorageDownloadTask(
37+
in session: URLSession,
38+
buildRequest: @escaping @Sendable () async throws -> URLRequest
39+
) -> StorageDownloadTask {
40+
let (eventStream, eventsContinuation) = AsyncStream<TransferEvent<URL>>.makeStream()
41+
let (resultStream, resultContinuation) = AsyncStream<Result<URL, any Error>>.makeStream(
42+
bufferingPolicy: .bufferingNewest(1))
43+
44+
let urlTaskRef = LockIsolated<URLSessionDownloadTask?>(nil)
45+
46+
let resultTask = Task<URL, any Error> {
47+
for await r in resultStream { return try r.get() }
48+
throw StorageError.cancelled
49+
}
50+
51+
// Bootstrap task: fetch the token, build the request, then start the download.
52+
let bootstrapTask = Task {
53+
do {
54+
let request = try await buildRequest()
55+
let urlTask = session.downloadTask(with: request)
56+
57+
state.withValue {
58+
$0.tasks[urlTask.taskIdentifier] = DownloadTaskState(
59+
eventsContinuation: eventsContinuation,
60+
resultContinuation: resultContinuation
61+
)
62+
}
63+
64+
urlTaskRef.setValue(urlTask)
65+
urlTask.resume()
66+
} catch {
67+
let storageError = StorageError.from(error)
68+
eventsContinuation.yield(.failed(storageError))
69+
eventsContinuation.finish()
70+
resultContinuation.yield(.failure(storageError))
71+
resultContinuation.finish()
72+
}
73+
}
74+
75+
eventsContinuation.onTermination = { [urlTaskRef] reason in
76+
guard case .cancelled = reason else { return }
77+
urlTaskRef.value?.cancel()
78+
bootstrapTask.cancel()
79+
}
80+
81+
return StorageDownloadTask(
82+
events: eventStream,
83+
resultTask: resultTask,
84+
pause: { urlTaskRef.value?.suspend() },
85+
resume: { urlTaskRef.value?.resume() },
86+
cancel: {
87+
bootstrapTask.cancel()
88+
urlTaskRef.value?.cancel()
89+
// Finish continuations in case the bootstrap was cancelled before the
90+
// URLSessionDownloadTask existed — otherwise observers hang forever.
91+
eventsContinuation.finish()
92+
resultContinuation.finish()
93+
}
94+
)
95+
}
96+
97+
/// Package-level access for tests to drive delegate callbacks directly.
98+
package func makeDownloadTask(
99+
in session: URLSession,
100+
request: URLRequest
101+
) -> (
102+
stream: AsyncStream<TransferEvent<URL>>,
103+
eventsContinuation: AsyncStream<TransferEvent<URL>>.Continuation,
104+
task: URLSessionDownloadTask
105+
) {
106+
let (eventStream, eventsContinuation) = AsyncStream<TransferEvent<URL>>.makeStream()
107+
let (resultStream, resultContinuation) = AsyncStream<Result<URL, any Error>>.makeStream(
108+
bufferingPolicy: .bufferingNewest(1))
109+
let urlTask = session.downloadTask(with: request)
110+
state.withValue {
111+
$0.tasks[urlTask.taskIdentifier] = DownloadTaskState(
112+
eventsContinuation: eventsContinuation,
113+
resultContinuation: resultContinuation
114+
)
115+
}
116+
_ = resultStream // satisfy unused warning — test uses stream directly via delegate callbacks
117+
_ = resultContinuation
118+
return (eventStream, eventsContinuation, urlTask)
119+
}
120+
121+
func setBackgroundCompletionHandler(_ handler: @escaping @Sendable () -> Void) {
122+
state.withValue { $0.backgroundCompletionHandler = handler }
123+
}
124+
125+
// MARK: - URLSessionDownloadDelegate
126+
127+
func urlSession(
128+
_ session: URLSession,
129+
downloadTask: URLSessionDownloadTask,
130+
didWriteData bytesWritten: Int64,
131+
totalBytesWritten: Int64,
132+
totalBytesExpectedToWrite: Int64
133+
) {
134+
state.withValue {
135+
guard let taskState = $0.tasks[downloadTask.taskIdentifier] else { return }
136+
taskState.eventsContinuation.yield(
137+
.progress(
138+
TransferProgress(
139+
bytesTransferred: totalBytesWritten,
140+
totalBytes: totalBytesExpectedToWrite
141+
)))
142+
}
143+
}
144+
145+
func urlSession(
146+
_ session: URLSession,
147+
downloadTask: URLSessionDownloadTask,
148+
didFinishDownloadingTo location: URL
149+
) {
150+
state.withValue {
151+
guard let taskState = $0.tasks[downloadTask.taskIdentifier] else { return }
152+
153+
let destination = FileManager.default.temporaryDirectory
154+
.appendingPathComponent(UUID().uuidString)
155+
156+
do {
157+
try FileManager.default.moveItem(at: location, to: destination)
158+
taskState.eventsContinuation.yield(.completed(destination))
159+
taskState.eventsContinuation.finish()
160+
taskState.resultContinuation.yield(.success(destination))
161+
taskState.resultContinuation.finish()
162+
} catch {
163+
let storageError = StorageError.fileSystemError(underlying: error)
164+
taskState.eventsContinuation.yield(.failed(storageError))
165+
taskState.eventsContinuation.finish()
166+
taskState.resultContinuation.yield(.failure(storageError))
167+
taskState.resultContinuation.finish()
168+
}
169+
$0.tasks.removeValue(forKey: downloadTask.taskIdentifier)
170+
}
171+
}
172+
173+
func urlSession(
174+
_ session: URLSession,
175+
task: URLSessionTask,
176+
didCompleteWithError error: (any Error)?
177+
) {
178+
guard let error else { return }
179+
180+
state.withValue {
181+
guard let taskState = $0.tasks[task.taskIdentifier] else { return }
182+
183+
let storageError: StorageError
184+
if (error as? URLError)?.code == .cancelled {
185+
storageError = .cancelled
186+
} else {
187+
storageError = .networkError(underlying: error)
188+
}
189+
190+
taskState.eventsContinuation.yield(.failed(storageError))
191+
taskState.eventsContinuation.finish()
192+
taskState.resultContinuation.yield(.failure(storageError))
193+
taskState.resultContinuation.finish()
194+
$0.tasks.removeValue(forKey: task.taskIdentifier)
195+
}
196+
}
197+
198+
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
199+
state.withValue {
200+
$0.backgroundCompletionHandler?()
201+
$0.backgroundCompletionHandler = nil
202+
}
203+
}
204+
}

Sources/Storage/StorageClient.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ public struct StorageClientConfiguration: Sendable {
5151
/// Defaults to `false`.
5252
public let useNewHostname: Bool
5353

54+
/// When set, downloads use `URLSessionConfiguration.background(withIdentifier:)`,
55+
/// allowing transfers to continue while the app is suspended.
56+
///
57+
/// Requires wiring `handleBackgroundEvents(forSessionIdentifier:completionHandler:)` in
58+
/// your `AppDelegate`.
59+
///
60+
/// When `nil` (the default), a standard foreground session is used.
61+
public var backgroundDownloadSessionIdentifier: String?
62+
5463
/// The TUS upload chunk size in bytes.
5564
///
5665
/// Files uploaded via the TUS resumable protocol are split into chunks of this size.
@@ -69,19 +78,23 @@ public struct StorageClientConfiguration: Sendable {
6978
/// - logger: An optional `SupabaseLogger` for request/response diagnostics. Defaults to `nil`.
7079
/// - useNewHostname: When `true`, rewrites the host to the dedicated storage subdomain for
7180
/// large-file upload support. Defaults to `false`.
81+
/// - backgroundDownloadSessionIdentifier: When set, downloads use a background
82+
/// `URLSessionConfiguration` with this identifier. Defaults to `nil`.
7283
/// - tusChunkSize: TUS upload chunk size in bytes. Also used as the threshold for the smart
7384
/// default `upload()`/`update()` methods. Defaults to 6 MB.
7485
public init(
7586
headers: [String: String],
7687
session: URLSession = URLSession(configuration: .default),
7788
logger: (any SupabaseLogger)? = nil,
7889
useNewHostname: Bool = false,
90+
backgroundDownloadSessionIdentifier: String? = nil,
7991
tusChunkSize: Int = 6 * 1024 * 1024
8092
) {
8193
self.headers = headers
8294
self.session = session
8395
self.logger = logger
8496
self.useNewHostname = useNewHostname
97+
self.backgroundDownloadSessionIdentifier = backgroundDownloadSessionIdentifier
8598
self.tusChunkSize = tusChunkSize
8699
}
87100
}
@@ -127,6 +140,9 @@ public final class StorageClient: Sendable {
127140
package let http: _HTTPClient
128141
private let usesTokenProvider: Bool
129142

143+
let downloadDelegate: DownloadSessionDelegate
144+
let downloadSession: URLSession
145+
130146
let encoder: JSONEncoder = {
131147
let encoder = JSONEncoder.supabase()
132148
encoder.keyEncodingStrategy = .convertToSnakeCase
@@ -223,6 +239,28 @@ public final class StorageClient: Sendable {
223239
tokenProvider: tokenProvider
224240
)
225241

242+
let downloadDelegate = DownloadSessionDelegate()
243+
self.downloadDelegate = downloadDelegate
244+
245+
#if canImport(Darwin)
246+
let downloadSessionConfig: URLSessionConfiguration =
247+
configuration.backgroundDownloadSessionIdentifier.map {
248+
.background(withIdentifier: $0)
249+
} ?? .default
250+
#else
251+
let downloadSessionConfig: URLSessionConfiguration = .default
252+
#endif
253+
// Propagate any custom protocol classes (e.g. for testing) from the HTTP session.
254+
if let protocolClasses = configuration.session.configuration.protocolClasses,
255+
!protocolClasses.isEmpty
256+
{
257+
downloadSessionConfig.protocolClasses = protocolClasses
258+
}
259+
self.downloadSession = URLSession(
260+
configuration: downloadSessionConfig,
261+
delegate: downloadDelegate,
262+
delegateQueue: nil
263+
)
226264
}
227265

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

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

354403
/// Returns a ``StorageFileAPI`` scoped to the given bucket.
355404
///

0 commit comments

Comments
 (0)