├── .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 |
--------------------------------------------------------------------------------