creaturesurvive/downpour
A persistent, resumable background download manager for iOS, macOS, tvOS, watchOS and visionOS.
What it handles for you
| Problem | Downpour | | --- | --- | | App is suspended or killed mid-download | Background session; the queue is persisted as JSON and reattached to live tasks on relaunch | | App was force-quit (the system cancels its tasks) | Lost tasks are re-queued, resuming from stored resume data when possible | | User pauses | cancel(byProducingResumeData:), stored on disk; resume sends a Range request | | Wi-Fi drops mid-transfer | Resume data is pulled from the error and retried automatically with exponential backoff | | Server returns 404, 500 or 503 | URLSession reports success with the error page as your "file". Downpour checks the status, never moves the error body into place, and retries only 408, 429 and 5xx | | Stale resume data | Discarded, and the download restarts cleanly | | Temporary file deleted before you move it | The move happens synchronously inside didFinishDownloadingTo; directories are created and existing files replaced | | Too many simultaneous transfers | Configurable concurrency limit and per-download priority | | Disk full | Checks free space against the expected size before starting | | iCloud backup bloat | Finished files are excluded from backup (configurable) | | handleEventsForBackgroundURLSession | Called for you once all events are delivered | | Events for tasks from a previous launch arriving before restoration | Tasks are adopted by identifier instead of being dropped |
Installation
Add Downpour to your Package.swift:
dependencies: [
.package(url: "https://github.com/CreatureSurvive/Downpour.git", from: "1.0.0"),
],
targets: [
.target(name: "MyApp", dependencies: ["Downpour"]),
]Or in Xcode, choose File › Add Package Dependencies… and enter https://github.com/CreatureSurvive/Downpour.
Requirements
| Platform | Minimum | | --- | --- | | iOS | 17.0 | | macOS | 14.0 | | tvOS | 17.0 | | watchOS | 10.0 | | visionOS | 1.0 |
Swift 6.1 (Xcode 16.4) or later, in Swift 6 language mode. No third-party dependencies.
Setup
Create one manager per session identifier at launch and keep it for the lifetime of the app:
extension DownloadManager {
static let shared = DownloadManager(configuration: {
var configuration = DownloadManager.Configuration(sessionIdentifier: "com.example.downloads")
configuration.maximumConcurrentDownloads = 2
configuration.allowsCellularAccess = false
return configuration
}())
}Forward background events (iOS, tvOS, visionOS):
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void) {
DownloadManager.shared.handleEventsForBackgroundURLSession(identifier: identifier, completionHandler: completionHandler)
}
}Usage
let manager = DownloadManager.shared
try manager.enqueue(request) // throws .duplicate if the id exists
try manager.enqueue(request, replacingExisting: true)
manager.pause(id)
manager.resume(id)
manager.retry(id)
manager.pauseAll()
manager.resumeAll()
manager.setPriority(10, for: id)
manager.remove(id, deleteFile: true)
manager.downloads // [Download], in enqueue order
manager.download(withID: id)?.progress // 0...1
manager.hasResumeData(for: id)
for await event in manager.eventStream() { // .added, .progress, .statusChanged, .removed
...
}DownloadRequest.userInfo persists arbitrary strings with the download, such as a title or a JSON-encoded model. That lets you rebuild your UI after relaunch without keeping a separate database.
SwiftUI
struct DownloadsView: View {
@State private var store = DownloadStore(manager: .shared)
var body: some View {
List(store.downloads) { download in
VStack(alignment: .leading) {
Text(download.request.userInfo["title"] ?? download.id)
switch download.status {
case .downloading, .queued, .waitingToRetry:
ProgressView(value: download.progress ?? 0)
case .paused: Text("Paused")
case .failed: Text(download.error?.localizedDescription ?? "Failed")
case .completed: Text("Downloaded")
}
}
}
}
}DownloadStore is @Observable. Progress events update only the affected entry.
Download lifecycle
queued ──▶ downloading ──▶ completed
▲ │ │ │
│ ▼ │ └──▶ waitingToRetry ──(backoff)──▶ queued
paused ◀──────┘ │
└──(attempts exhausted / permanent error)──▶ failed ──(retry/resume)──▶ queuedTesting
swift test drives real URLSession transfers against an in-process HTTP server that supports byte ranges, throttled streaming, flaky 503s and connections that drop mid-body. The tests cover completion, HTTP error handling, retry and backoff, resume after a dropped connection (verified to use Range), pause and resume, concurrency limits, priorities, relaunch restoration, duplicates, removal and storage checks. The suite is clean under Thread Sanitizer.
Tests use a foreground session (sessionIdentifier: nil) because background sessions need an app bundle. The same code paths are used for both.
Changelog
See CHANGELOG.md. Releases follow Semantic Versioning.
Contributing
Issues and pull requests are welcome. Please run swift test before opening a pull request, and add tests for new behavior.
License
Available under the MIT license. See LICENSE for details.
Package Metadata
Repository: creaturesurvive/downpour
Default branch: main
README: README.md