├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .spi.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── Background │ ├── BackgroundTaskConfiguration.swift │ ├── Downloader.swift │ ├── NetworkResponse.swift │ ├── TaskScheduler.swift │ ├── URLResponse+Utilities.swift │ ├── URLSessionDelegateAdapter.swift │ └── Uploader.swift └── Tests └── BackgroundTests ├── BackgroundTests.swift ├── HTTPHeaderTests.swift └── NetworkResponseTests.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [mattmassicotte] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - 'README.md' 9 | - 'CODE_OF_CONDUCT.md' 10 | - '.editorconfig' 11 | - '.spi.yml' 12 | pull_request: 13 | branches: 14 | - main 15 | 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | test: 22 | name: Test 23 | runs-on: macOS-15 24 | timeout-minutes: 30 25 | env: 26 | DEVELOPER_DIR: /Applications/Xcode_16.2.app 27 | strategy: 28 | matrix: 29 | destination: 30 | - "platform=macOS" 31 | - "platform=macOS,variant=Mac Catalyst" 32 | - "platform=iOS Simulator,name=iPhone 16" 33 | - "platform=tvOS Simulator,name=Apple TV" 34 | - "platform=watchOS Simulator,name=Apple Watch Series 10 (42mm)" 35 | - "platform=visionOS Simulator,name=Apple Vision Pro" 36 | steps: 37 | - uses: actions/checkout@v4 38 | - name: Test platform ${{ matrix.destination }} 39 | run: set -o pipefail && xcodebuild -scheme Background -destination "${{ matrix.destination }}" test | xcbeautify 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [Background] 5 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | support@chimehq.com. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Chime 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Background", 7 | platforms: [ 8 | .macOS(.v11), 9 | .iOS(.v14), 10 | .tvOS(.v14), 11 | .watchOS(.v7), 12 | .macCatalyst(.v14), 13 | ], 14 | products: [ 15 | .library(name: "Background", targets: ["Background"]), 16 | ], 17 | targets: [ 18 | .target(name: "Background"), 19 | .testTarget(name: "BackgroundTests", dependencies: ["Background"]), 20 | ] 21 | ) 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | [![Build Status][build status badge]][build status] 4 | [![Platforms][platforms badge]][platforms] 5 | [![Documentation][documentation badge]][documentation] 6 | [![Matrix][matrix badge]][matrix] 7 | 8 |
9 | 10 | # Background 11 | Background Tasks and Networking 12 | 13 | ## Integration 14 | 15 | ```swift 16 | dependencies: [ 17 | .package(url: "https://github.com/ChimeHQ/Background", branch: "main") 18 | ] 19 | ``` 20 | 21 | ## Concept 22 | 23 | [`URLSession`](https://developer.apple.com/documentation/foundation/urlsession)'s background upload and download facilities are relatively straightforward to get started with. But, they are surprisingly difficult to manage. The core challenge is an operation could start and/or complete while your process isn't even running. You cannot just wait for a completion handler or `await` call. This usually means you have to involve persistent storage to juggle state across process launches. 24 | 25 | You also typically need to make use of system-provided API to reconnect your session to any work that has happened between launches. This can be done a few different ways, depending on your type of project and how you'd like your system to work. 26 | 27 | - [`WidgetConfiguration.onBackgroundSessionEvents(matching:_:)`](https://developer.apple.com/documentation/swiftui/widgetconfiguration/onbackgroundurlsessionevents(matching:_:)-2e152) 28 | - [`UIApplicationDelegate.application(_:handleEventsForBackgroundURLSession:completionHandler:)`](https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1622941-application) 29 | 30 | Because persistent state is involved and networking operations might already be in-progress, the `Uploader` and `Downloader` types support restarting operations. Your job is to track that in-flight work. You can then just restart any work that hasn't yet completed on launch using the `Uploader` or `Downloader` types. They will take care of determining if the operations need to be actually started or just reconnected to existing work. 31 | 32 | ## Usage 33 | 34 | ### Uploading 35 | 36 | ```swift 37 | import Foundation 38 | import Background 39 | 40 | let config = URLSessionConfiguration.background(withIdentifier: "com.my.background-id") 41 | let uploader = Uploader(sessionConfiguration: config) 42 | 43 | let request = URLRequest(url: URL(string: "https://myurl")!) 44 | let url = URL(fileURLWithPath: "path/to/file") 45 | let identifier = "some-stable-id-appropriate-for-the-file" 46 | 47 | Task { 48 | // remember, this await may not return during the processes entire lifecycle! 49 | let response = await uploader.networkResponse(from: request, uploading: url, with: identifier) 50 | 51 | switch response { 52 | case .rejected: 53 | // the server did not like the request 54 | break 55 | case let .retry(urlResponse): 56 | let interval = urlResponse.retryAfterInterval ?? 60.0 57 | 58 | // transient failure, could retry with interval if that makes sense 59 | break 60 | case let .failed(error): 61 | // failed and a retry is unlikely to succeed 62 | break 63 | case let .success(urlResponse): 64 | // upload completed successfully 65 | break 66 | } 67 | } 68 | ``` 69 | 70 | ### Downloading 71 | 72 | ```swift 73 | import Foundation 74 | import Background 75 | 76 | let config = URLSessionConfiguration.background(withIdentifier: "com.my.background-id") 77 | let downloader = Downloader(sessionConfiguration: config) 78 | 79 | let request = URLRequest(url: URL(string: "https://myurl")!) 80 | let identifier = "some-stable-id-appropriate-for-the-file" 81 | 82 | Task { 83 | // remember, this await may not return during the processes entire lifecycle! 84 | let response = await downloader.networkResponse(from: request, with: identifier) 85 | 86 | switch response { 87 | case .rejected: 88 | // the server did not like the request 89 | break 90 | case let .retry(urlResponse): 91 | let interval = urlResponse.retryAfterInterval ?? 60.0 92 | 93 | // transient failure, could retry with interval if that makes sense 94 | break 95 | case let .failed(error): 96 | // failed and a retry is unlikely to succeed 97 | break 98 | case let .success(url, urlResponse): 99 | // download completed successfully at url 100 | break 101 | } 102 | } 103 | ``` 104 | 105 | ### Widget Support 106 | 107 | If you are making use of `Downloader` in a widget, you must reconnect the session as part of your `WidgetConfiguration`. Here's how: 108 | 109 | ```swift 110 | struct YourWidget: Widget { 111 | var body: some WidgetConfiguration { 112 | StaticConfiguration(kind: kind, provider: provider) { entry in 113 | YourWidgetView() 114 | } 115 | .onBackgroundURLSessionEvents { identifier, completion in 116 | // find/create your downloader instance using the system-supplied 117 | // identifier 118 | let downloader = lookupDownloader(with: identifier) 119 | 120 | // and allow it to handle the events, possibly resulting in 121 | // callbacks and/or async functions completing 122 | downloader.handleBackgroundSessionEvents(completion) 123 | } 124 | } 125 | } 126 | ``` 127 | 128 | ### Background Tasks 129 | 130 | It's disappointing that the [BackgroundTasks](https://developer.apple.com/documentation/backgroundtasks) framework isn't available for macOS. The library includes some preliminary work to build a nearly source-compatible version of `BGTaskScheduler` that works across all platforms. This replacement also permits unconditional use in the simulator, which makes it more convenient. Actual background work will still only take place on real devices. 131 | 132 | The macOS implementation is build around [NSBackgroundActivityScheduler](https://developer.apple.com/documentation/foundation/nsbackgroundactivityscheduler), which works very differently internally. 133 | 134 | As of right now, these types aren't public because the work isn't complete. 135 | 136 | ### More Complex Usage 137 | 138 | This package is used to manage the background uploading facilities of [Wells](https://github.com/ChimeHQ/Wells), a diagnostics report submission system. You can check out that project for a much more complex example of how to manage background uploads. 139 | 140 | ## Contributing and Collaboration 141 | 142 | I would love to hear from you! Issues or pull requests work great. Both a [Matrix space][matrix] and [Discord][discord] are available for live help, but I have a strong bias towards answering in the form of documentation. You can also find me on [the web](https://www.massicotte.org). 143 | 144 | I prefer collaboration, and would love to find ways to work together if you have a similar project. 145 | 146 | I prefer indentation with tabs for improved accessibility. But, I'd rather you use the system you want and make a PR than hesitate because of whitespace. 147 | 148 | By participating in this project you agree to abide by the [Contributor Code of Conduct](CODE_OF_CONDUCT.md). 149 | 150 | [build status]: https://github.com/ChimeHQ/Background/actions 151 | [build status badge]: https://github.com/ChimeHQ/Background/workflows/CI/badge.svg 152 | [platforms]: https://swiftpackageindex.com/ChimeHQ/Background 153 | [platforms badge]: https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FChimeHQ%2FBackground%2Fbadge%3Ftype%3Dplatforms 154 | [documentation]: https://swiftpackageindex.com/ChimeHQ/Background/main/documentation 155 | [documentation badge]: https://img.shields.io/badge/Documentation-DocC-blue 156 | [matrix]: https://matrix.to/#/%23chimehq%3Amatrix.org 157 | [matrix badge]: https://img.shields.io/matrix/chimehq%3Amatrix.org?label=Matrix 158 | [discord]: https://discord.gg/esFpX6sErJ 159 | -------------------------------------------------------------------------------- /Sources/Background/BackgroundTaskConfiguration.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Interface to long-running URLSessionTask objects. 4 | public struct BackgroundTaskConfiguration: Sendable { 5 | public typealias IdentifierProvider = @Sendable (URLSessionTask) -> String? 6 | public typealias PrepareTask = @Sendable (URLSessionTask, URLRequest, String) -> Void 7 | 8 | public let getIdentifier: IdentifierProvider 9 | public let prepareTask: PrepareTask 10 | 11 | public init( 12 | getIdentifier: @escaping IdentifierProvider, 13 | prepareTask: @escaping PrepareTask = { _, _, _ in } 14 | ) { 15 | self.getIdentifier = getIdentifier 16 | self.prepareTask = prepareTask 17 | } 18 | 19 | /// Stores a persistent identifier within the `URLSessionTask`'s `taskDescription` property. 20 | public static let taskDescriptionCoder = BackgroundTaskConfiguration( 21 | getIdentifier: { $0.taskDescription }, 22 | prepareTask: { $0.taskDescription = $2 } 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Background/Downloader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import OSLog 3 | 4 | /// Manages background downloads 5 | public actor Downloader { 6 | public typealias Identifier = String 7 | public typealias Handler = @Sendable (Identifier, Result<(URL, URLResponse), Error>) -> Void 8 | 9 | private let session: URLSession 10 | private var handlers = [Identifier: Handler]() 11 | private let logger = Logger(subsystem: "com.chimehq.Background", category: "Uploader") 12 | private let taskInterface: BackgroundTaskConfiguration 13 | private var pendingEvents = true 14 | private var sessionCompletionHandler: () -> Void = {} 15 | 16 | public init( 17 | sessionConfiguration: URLSessionConfiguration, 18 | taskConfiguration: BackgroundTaskConfiguration = BackgroundTaskConfiguration.taskDescriptionCoder 19 | ) { 20 | let adapter = URLSessionDelegateAdapter() 21 | 22 | self.taskInterface = taskConfiguration 23 | self.session = URLSession(configuration: sessionConfiguration, delegate: adapter, delegateQueue: nil) 24 | 25 | Task { [weak self] in 26 | for await event in adapter.eventStream { 27 | guard let self else { break } 28 | 29 | switch event { 30 | case .didFinishEvents: 31 | await self.finishedEvents() 32 | case let .taskComplete(task, error): 33 | await self.taskFinished(task, with: error, url: nil) 34 | case let .downloadFinished(task, url): 35 | await self.taskFinished(task, with: nil, url: url) 36 | } 37 | } 38 | } 39 | } 40 | 41 | deinit { 42 | session.invalidateAndCancel() 43 | } 44 | 45 | public init( 46 | sessionConfiguration: URLSessionConfiguration, 47 | identifierProvider: @escaping BackgroundTaskConfiguration.IdentifierProvider 48 | ) { 49 | self.init( 50 | sessionConfiguration: sessionConfiguration, 51 | taskConfiguration: BackgroundTaskConfiguration( 52 | getIdentifier: identifierProvider 53 | ) 54 | ) 55 | } 56 | 57 | private var activeIdentifiers: Set { 58 | get async { 59 | let (_, _, downloadTasks) = await session.tasksWhenAvailable 60 | let ids = downloadTasks.compactMap { taskInterface.getIdentifier($0) } 61 | 62 | return Set(ids) 63 | } 64 | } 65 | } 66 | 67 | extension Downloader { 68 | public func beginDownload( 69 | of request: URLRequest, 70 | identifier: String, 71 | handler: @escaping Handler 72 | ) { 73 | precondition(handlers[identifier] == nil) 74 | 75 | handlers[identifier] = handler 76 | 77 | Task { 78 | let ids = await activeIdentifiers 79 | 80 | if ids.contains(identifier) { 81 | logger.debug("found existing task for \(identifier, privacy: .public)") 82 | return 83 | } 84 | 85 | let task = self.session.downloadTask(with: request) 86 | 87 | taskInterface.prepareTask(task, request, identifier) 88 | 89 | task.resume() 90 | } 91 | } 92 | 93 | public func download( 94 | _ request: URLRequest, 95 | with identifier: String 96 | ) async throws -> (URL, URLResponse) { 97 | try await withCheckedThrowingContinuation { continuation in 98 | beginDownload(of: request, identifier: identifier) { _, response in 99 | continuation.resume(with: response) 100 | } 101 | } 102 | } 103 | 104 | /// Start a download task and return a NetworkResponse when complete. 105 | public func networkResponse( 106 | from request: URLRequest, 107 | with identifier: String 108 | ) async -> NetworkResponse { 109 | do { 110 | let result = try await download(request, with: identifier) 111 | 112 | return NetworkResponse(response: result.1, value: result.0) 113 | } catch { 114 | return NetworkResponse(response: nil, error: error, value: nil) 115 | } 116 | } 117 | } 118 | 119 | extension Downloader { 120 | private func finishedEvents() async { 121 | if pendingEvents == false { 122 | logger.debug("skipping pending events after the first") 123 | return 124 | } 125 | 126 | self.pendingEvents = false 127 | 128 | await withCheckedContinuation { continuation in 129 | self.sessionCompletionHandler = { 130 | continuation.resume() 131 | } 132 | } 133 | } 134 | 135 | private func taskFinished(_ task: URLSessionTask, with error: Error?, url: URL?) { 136 | guard task is URLSessionDownloadTask else { return } 137 | let identifier = taskInterface.getIdentifier(task) ?? "" 138 | 139 | logger.info("completed download task: \(identifier, privacy: .public)") 140 | 141 | let response = task.responseResult(with: error) 142 | 143 | downloadFinished(with: response, identifier: identifier, location: url) 144 | } 145 | 146 | private func downloadFinished(with result: Result, identifier: Identifier, location: URL?) { 147 | guard let handler = handlers[identifier] else { 148 | logger.info("no handler found for \(identifier, privacy: .public)") 149 | 150 | return 151 | } 152 | 153 | switch (result, location) { 154 | case let (.failure(error), nil): 155 | handler(identifier, .failure(error)) 156 | case (.success, nil): 157 | handler(identifier, .failure(NetworkResponseError.expectedContentMissing)) 158 | case let (.success(response), url?): 159 | handler(identifier, .success((url, response))) 160 | 161 | try? FileManager.default.removeItem(at: url) 162 | case let (.failure(error), url?): 163 | handler(identifier, .failure(error)) 164 | 165 | try? FileManager.default.removeItem(at: url) 166 | } 167 | } 168 | 169 | public nonisolated func handleBackgroundSessionEvents(_ completion: @escaping @Sendable () -> Void) { 170 | Task { 171 | await finishedEvents() 172 | 173 | completion() 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /Sources/Background/NetworkResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum NetworkResponseError: Error { 4 | case protocolError(Error) 5 | case noResponseOrError 6 | case noHTTPResponse(URLResponse) 7 | case httpReponseInvalid 8 | case requestInvalid 9 | case missingOriginalRequest 10 | case transientFailure(TimeInterval?) 11 | case expectedContentMissing 12 | } 13 | 14 | public enum NetworkResponse { 15 | case failed(NetworkResponseError) 16 | case rejected 17 | case retry(HTTPURLResponse) 18 | case success(Value, HTTPURLResponse) 19 | } 20 | 21 | extension NetworkResponse : Sendable where Value : Sendable {} 22 | 23 | extension NetworkResponse: CustomStringConvertible { 24 | public var description: String { 25 | switch self { 26 | case .failed(let e): return "failed (\(e))" 27 | case .rejected: return "rejected" 28 | case .retry: return "retry" 29 | case let .success(value, response): return "success (\(response.statusCode)) \(value)" 30 | } 31 | } 32 | } 33 | 34 | extension NetworkResponse { 35 | public init(response: URLResponse?, error: Error? = nil, value: Value?) { 36 | if let e = error { 37 | self = .failed(NetworkResponseError.protocolError(e)) 38 | return 39 | } 40 | 41 | guard let response = response else { 42 | self = .failed(NetworkResponseError.noResponseOrError) 43 | return 44 | } 45 | 46 | guard let httpResponse = response as? HTTPURLResponse else { 47 | self = .failed(NetworkResponseError.noHTTPResponse(response)) 48 | return 49 | } 50 | 51 | let code = httpResponse.statusCode 52 | 53 | switch code { 54 | case 0..<200: 55 | self = NetworkResponse.failed(NetworkResponseError.httpReponseInvalid) 56 | case 200, 201, 202, 204: 57 | if let value = value { 58 | self = .success(value, httpResponse) 59 | } else { 60 | self = .failed(NetworkResponseError.expectedContentMissing) 61 | } 62 | case 408, 429, 500, 502, 503, 504: 63 | self = NetworkResponse.retry(httpResponse) 64 | default: 65 | self = NetworkResponse.rejected 66 | } 67 | } 68 | } 69 | 70 | extension NetworkResponse where Value == Void { 71 | public init(response: URLResponse?, error: Error? = nil) { 72 | self.init(response: response, error: error, value: ()) 73 | } 74 | 75 | public init(with result: Result) { 76 | switch result { 77 | case let .success(response): 78 | self.init(response: response, error: nil) 79 | case let .failure(error): 80 | self.init(response: nil, error: error) 81 | } 82 | } 83 | } 84 | 85 | extension URLResponse { 86 | public var httpResponse: HTTPURLResponse { 87 | get throws { 88 | guard let httpResp = self as? HTTPURLResponse else { 89 | throw NetworkResponseError.noHTTPResponse(self) 90 | } 91 | 92 | return httpResp 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/Background/TaskScheduler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if os(iOS) || os(tvOS) || os(visionOS) 3 | import BackgroundTasks 4 | #endif 5 | 6 | protocol BackgroundTaskRequest: Hashable { 7 | var identifier: String { get } 8 | var earliestBeginDate: Date? { get } 9 | } 10 | 11 | enum TaskSchedulerError: Error { 12 | case unsupportedRequest 13 | case notSupported 14 | } 15 | 16 | #if os(iOS) || os(tvOS) || os(visionOS) 17 | struct BackgroundTask { 18 | private let task: BGTask 19 | 20 | init(_ task: BGTask) { 21 | self.task = task 22 | } 23 | 24 | public var identifier: String { task.identifier } 25 | 26 | public var expirationHandler: (@Sendable () -> Void)? { 27 | get { 28 | unsafeBitCast(task.expirationHandler, to: (@Sendable () -> Void)?.self) 29 | } 30 | set { task.expirationHandler = newValue } 31 | } 32 | 33 | public func setTaskCompleted(success: Bool) { 34 | task.setTaskCompleted(success: success) 35 | } 36 | } 37 | 38 | extension BackgroundTaskRequest { 39 | var bgTaskRequest: BGTaskRequest { 40 | get throws { 41 | switch self { 42 | case is AppRefreshTaskRequest: 43 | let request = BGAppRefreshTaskRequest(identifier: identifier) 44 | 45 | request.earliestBeginDate = earliestBeginDate 46 | 47 | return request 48 | case let processing as ProcessingTaskRequest: 49 | let request = BGProcessingTaskRequest(identifier: identifier) 50 | 51 | request.earliestBeginDate = earliestBeginDate 52 | request.requiresNetworkConnectivity = processing.requiresNetworkConnectivity 53 | request.requiresExternalPower = processing.requiresExternalPower 54 | 55 | return request 56 | default: 57 | throw TaskSchedulerError.unsupportedRequest 58 | } 59 | } 60 | } 61 | } 62 | #else 63 | struct BackgroundTask { 64 | public let identifier: String 65 | public let expirationHandler: (@Sendable () -> Void)? 66 | 67 | public func setTaskCompleted(success: Bool) { 68 | } 69 | } 70 | #endif 71 | 72 | struct AppRefreshTaskRequest: BackgroundTaskRequest { 73 | public let identifier: String 74 | public var earliestBeginDate: Date? 75 | 76 | init(identifier: String) { 77 | self.identifier = identifier 78 | } 79 | } 80 | 81 | struct ProcessingTaskRequest: BackgroundTaskRequest { 82 | public let identifier: String 83 | public var earliestBeginDate: Date? 84 | public var requiresNetworkConnectivity: Bool = false 85 | public var requiresExternalPower: Bool = false 86 | 87 | init(identifier: String) { 88 | self.identifier = identifier 89 | } 90 | } 91 | 92 | #if os(iOS) || os(tvOS) || os(visionOS) 93 | final class TaskScheduler: Sendable { 94 | public static let shared = TaskScheduler() 95 | 96 | private init() { 97 | } 98 | 99 | public func submit(_ task: any BackgroundTaskRequest) throws { 100 | #if targetEnvironment(simulator) 101 | return 102 | #else 103 | let bgTaskRequest = try task.bgTaskRequest 104 | 105 | try BGTaskScheduler.shared.submit(bgTaskRequest) 106 | #endif 107 | } 108 | 109 | public func register( 110 | forTaskWithIdentifier identifier: String, 111 | using queue: dispatch_queue_t? = nil, 112 | launchHandler: @escaping @Sendable (BackgroundTask) -> Void 113 | ) -> Bool { 114 | #if targetEnvironment(simulator) 115 | return true 116 | #else 117 | 118 | BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: queue) { @Sendable bgTask in 119 | let task = BackgroundTask(bgTask) 120 | 121 | launchHandler(task) 122 | } 123 | #endif 124 | } 125 | } 126 | #endif 127 | 128 | #if os(iOS) || os(tvOS) || os(visionOS) 129 | extension BGTaskScheduler { 130 | public func register( 131 | forTaskWithIdentifier identifier: String, 132 | launchHandler: @escaping @Sendable (BGTask) -> Void 133 | ) -> Bool { 134 | register(forTaskWithIdentifier: identifier, using: nil, launchHandler: launchHandler) 135 | } 136 | } 137 | #endif 138 | 139 | #if os(macOS) 140 | final class TaskScheduler: @unchecked Sendable { 141 | public static let shared = TaskScheduler() 142 | 143 | private let scheduler = NSBackgroundActivityScheduler(identifier: "com.chimehq.Background") 144 | private let lock = NSLock() 145 | private var requests: [String: any BackgroundTaskRequest] = [:] 146 | private var registrations: [String: HandlerRegistration] = [:] 147 | 148 | private struct HandlerRegistration: Sendable { 149 | let handler: @Sendable (BackgroundTask) -> Void 150 | let identifier: String 151 | let queue: dispatch_queue_t? 152 | } 153 | 154 | private init() { 155 | } 156 | 157 | public func submit(_ task: T) throws { 158 | lock.withLock { 159 | requests[task.identifier] = task 160 | } 161 | } 162 | 163 | public func register( 164 | forTaskWithIdentifier identifier: String, 165 | using queue: dispatch_queue_t? = nil, 166 | launchHandler: @escaping @Sendable (BackgroundTask) -> Void 167 | ) -> Bool { 168 | let registration = HandlerRegistration(handler: launchHandler, identifier: identifier, queue: queue) 169 | 170 | lock.withLock { 171 | registrations[identifier] = registration 172 | } 173 | 174 | return true 175 | } 176 | } 177 | #endif 178 | -------------------------------------------------------------------------------- /Sources/Background/URLResponse+Utilities.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension HTTPURLResponse { 4 | /// Returns the Retry-After HTTP header as a TimeInterval, if present 5 | public var retryAfterInterval: TimeInterval? { 6 | allHeaderFields["Retry-After"] 7 | .flatMap { $0 as? String } 8 | .flatMap { Int($0) } 9 | .map { TimeInterval($0) } 10 | } 11 | } 12 | 13 | extension URLSessionTask { 14 | /// Returns the task's response Retry-After HTTP header as a TimeInterval, if present 15 | public var retryAfterInterval: TimeInterval? { 16 | guard let response else { 17 | return nil 18 | } 19 | 20 | guard let httpResponse = response as? HTTPURLResponse else { 21 | return nil 22 | } 23 | 24 | return httpResponse.retryAfterInterval 25 | } 26 | 27 | func responseResult(with error: Error?) -> Result { 28 | switch (response, error) { 29 | case let (_, error?): 30 | .failure(error) 31 | case let (urlResponse?, nil): 32 | .success(urlResponse) 33 | case (nil, nil): 34 | .failure(NetworkResponseError.noResponseOrError) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Background/URLSessionDelegateAdapter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Intermediate object to use an actor type as a URLSessionDelegate 4 | final class URLSessionDelegateAdapter: NSObject { 5 | enum Event: Sendable { 6 | case didFinishEvents 7 | case taskComplete(URLSessionTask, Error?) 8 | case downloadFinished(URLSessionDownloadTask, URL) 9 | } 10 | 11 | private let streamPair = AsyncStream.makeStream() 12 | 13 | public var eventStream: AsyncStream { 14 | streamPair.0 15 | } 16 | } 17 | 18 | extension URLSessionDelegateAdapter: URLSessionDelegate { 19 | func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { 20 | streamPair.1.yield(.didFinishEvents) 21 | } 22 | } 23 | 24 | extension URLSessionDelegateAdapter: URLSessionTaskDelegate { 25 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 26 | streamPair.1.yield(.taskComplete(task, error)) 27 | } 28 | } 29 | 30 | extension URLSessionDelegateAdapter: URLSessionDownloadDelegate { 31 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { 32 | streamPair.1.yield(.downloadFinished(downloadTask, location)) 33 | } 34 | } 35 | 36 | extension URLSession { 37 | var tasksWhenAvailable: ([URLSessionDataTask], [URLSessionUploadTask], [URLSessionDownloadTask]) { 38 | get async { 39 | // force a turn of the main runloop, which I have found to sometimes be necessary for this to actually work 40 | await withCheckedContinuation { continuation in 41 | DispatchQueue.main.async { 42 | continuation.resume() 43 | } 44 | } 45 | 46 | return await tasks 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/Background/Uploader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import OSLog 3 | 4 | /// Manages background uploads 5 | public actor Uploader { 6 | public typealias Identifier = String 7 | public typealias Handler = @Sendable (Identifier, Result) -> Void 8 | 9 | private let session: URLSession 10 | private var handlers = [Identifier: Handler]() 11 | private let logger = Logger(subsystem: "com.chimehq.Background", category: "Uploader") 12 | private let taskInterface: BackgroundTaskConfiguration 13 | 14 | public init( 15 | sessionConfiguration: URLSessionConfiguration, 16 | taskConfiguration: BackgroundTaskConfiguration = BackgroundTaskConfiguration.taskDescriptionCoder 17 | ) { 18 | let adapter = URLSessionDelegateAdapter() 19 | 20 | self.taskInterface = taskConfiguration 21 | self.session = URLSession(configuration: sessionConfiguration, delegate: adapter, delegateQueue: nil) 22 | 23 | Task { [weak self] in 24 | for await event in adapter.eventStream { 25 | guard let self else { break } 26 | 27 | switch event { 28 | case let .taskComplete(task, error): 29 | await self.taskFinished(task, with: error) 30 | case .didFinishEvents, .downloadFinished: 31 | break 32 | } 33 | } 34 | } 35 | } 36 | 37 | public init( 38 | sessionConfiguration: URLSessionConfiguration, 39 | identifierProvider: @escaping BackgroundTaskConfiguration.IdentifierProvider 40 | ) { 41 | self.init( 42 | sessionConfiguration: sessionConfiguration, 43 | taskConfiguration: BackgroundTaskConfiguration( 44 | getIdentifier: identifierProvider 45 | ) 46 | ) 47 | } 48 | 49 | deinit { 50 | session.invalidateAndCancel() 51 | } 52 | 53 | private var activeIdentifiers: Set { 54 | get async { 55 | let (_, uploadTasks, _) = await session.tasksWhenAvailable 56 | let ids = uploadTasks.compactMap { taskInterface.getIdentifier($0) } 57 | 58 | return Set(ids) 59 | } 60 | } 61 | } 62 | 63 | extension Uploader { 64 | /// Start the upload task, calling handler when complete. 65 | /// 66 | /// While this function begins the process, background uploads may never even **begin** until the process has already exited. You should not expect that your handler be called quickly. 67 | /// 68 | /// You should track pending upload identifiers and re-invoke this method on subsequent launches. 69 | /// 70 | /// - Warning: there is no guarantee that `handler` is called during this current processes life-cycle. It may only be called on a future launch. 71 | /// 72 | public func beginUpload( 73 | of url: URL, 74 | with request: URLRequest, 75 | identifier: String, 76 | handler: @escaping Handler 77 | ) { 78 | precondition(handlers[identifier] == nil) 79 | handlers[identifier] = handler 80 | 81 | Task { 82 | let ids = await self.activeIdentifiers 83 | 84 | if ids.contains(identifier) { 85 | logger.debug("found existing task for \(identifier, privacy: .public)") 86 | return 87 | } 88 | 89 | let uploadTask = session.uploadTask(with: request, fromFile: url) 90 | 91 | taskInterface.prepareTask(uploadTask, request, identifier) 92 | 93 | uploadTask.resume() 94 | } 95 | } 96 | 97 | /// Start the upload task and return the response when complete. 98 | /// 99 | /// While this function begins the process, background uploads may never even **begin** until the process has already exited. You should not expect that this function will return quickly. 100 | /// 101 | /// You should track pending upload identifiers and re-invoke this method on subsequent launches. 102 | /// 103 | /// - Warning: there is no guarantee this function returns during this current processes life-cycle. It may only produce a result on a future launch. 104 | /// 105 | public func uploadFile( 106 | at url: URL, 107 | with request: URLRequest, 108 | identifier: String 109 | ) async throws -> URLResponse { 110 | try await withCheckedThrowingContinuation { continuation in 111 | beginUpload(of: url, with: request, identifier: identifier) { _, response in 112 | continuation.resume(with: response) 113 | } 114 | } 115 | } 116 | 117 | /// Start an upload task and return a NetworkResponse when complete. 118 | public func networkResponse( 119 | from request: URLRequest, 120 | uploading url: URL, 121 | with identifier: String 122 | ) async -> NetworkResponse { 123 | do { 124 | let response = try await uploadFile(at: url, with: request, identifier: identifier) 125 | 126 | return NetworkResponse(response: response) 127 | } catch { 128 | return NetworkResponse(response: nil, error: error) 129 | } 130 | } 131 | 132 | private func taskFinished(_ task: URLSessionTask, with error: Error?) { 133 | guard task is URLSessionUploadTask else { return } 134 | let identifier = taskInterface.getIdentifier(task) ?? "" 135 | 136 | logger.info("completed upload task: \(identifier, privacy: .public)") 137 | 138 | let response = task.responseResult(with: error) 139 | 140 | uploadFinished(with: response, identifier: identifier) 141 | } 142 | 143 | private func uploadFinished(with response: Result, identifier: Identifier) { 144 | guard let handler = handlers[identifier] else { 145 | logger.info("no handler found for \(identifier, privacy: .public)") 146 | 147 | return 148 | } 149 | 150 | handlers[identifier] = nil 151 | handler(identifier, response) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Tests/BackgroundTests/BackgroundTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | @testable import Background 3 | 4 | #if os(macOS) || os(iOS) || os(tvOS) || os(visionOS) 5 | struct BackgroundTests { 6 | @Test func registerAndSubmitProcessingTask() throws { 7 | let identifier = "test-processing-task" 8 | let request = ProcessingTaskRequest(identifier: identifier) 9 | 10 | let scheduler = TaskScheduler.shared 11 | 12 | let registered = scheduler.register(forTaskWithIdentifier: identifier) { task in 13 | 14 | } 15 | 16 | #expect(registered) 17 | 18 | try scheduler.submit(request) 19 | } 20 | } 21 | #endif 22 | -------------------------------------------------------------------------------- /Tests/BackgroundTests/HTTPHeaderTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Background 3 | 4 | final class HTTPHeaderTests: XCTestCase { 5 | func testRetryAfter() throws { 6 | let response = HTTPURLResponse(url: URL(string: "http://example.com")!, 7 | statusCode: 200, 8 | httpVersion: "1.1", 9 | headerFields: ["Retry-After": "120"]) 10 | 11 | XCTAssertEqual(response?.retryAfterInterval, 120.0) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tests/BackgroundTests/NetworkResponseTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import Background 4 | 5 | final class NetworkResponseTests: XCTestCase { 6 | func testDefaultRetryAfter() throws { 7 | let urlResponse = HTTPURLResponse(url: URL(string: "http://example.com")!, 8 | statusCode: 429, 9 | httpVersion: "1.1", 10 | headerFields: [:]) 11 | 12 | let response = NetworkResponse(response: urlResponse) 13 | 14 | switch response { 15 | case .retry: 16 | break 17 | default: 18 | XCTFail() 19 | } 20 | } 21 | } 22 | --------------------------------------------------------------------------------