├── .gitignore ├── LICENSE.md ├── Package.swift ├── README.md ├── Sources ├── Publisher+Async.swift ├── URLSession+Async.swift └── View+Async.swift └── Tests ├── PublisherTests.swift ├── URLSessionTests.swift └── ViewTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/ -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 John Sundell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | 3 | /** 4 | * AsyncCompatibilityKit 5 | * Copyright (c) John Sundell 2021 6 | * MIT license, see LICENSE.md file for details 7 | */ 8 | 9 | import PackageDescription 10 | 11 | let package = Package( 12 | name: "AsyncCompatibilityKit", 13 | platforms: [.iOS(.v13)], 14 | products: [ 15 | .library( 16 | name: "AsyncCompatibilityKit", 17 | targets: ["AsyncCompatibilityKit"] 18 | ) 19 | ], 20 | targets: [ 21 | .target( 22 | name: "AsyncCompatibilityKit", 23 | path: "Sources" 24 | ), 25 | .testTarget( 26 | name: "AsyncCompatibilityKitTests", 27 | dependencies: ["AsyncCompatibilityKit"], 28 | path: "Tests" 29 | ) 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AsyncCompatibilityKit 2 | 3 | Welcome to **AsyncCompatibilityKit**, a lightweight Swift package that adds iOS 13-compatible backports of commonly used `async/await`-based system APIs that are only available from iOS 15 by default. 4 | 5 | It currently includes backward compatible versions of the following APIs: 6 | 7 | - `URLSession.data(from: URL)` 8 | - `URLSession.data(for: URLRequest)` 9 | - `Combine.Publisher.values` 10 | - `SwiftUI.View.task` 11 | 12 | All of the included backports have signatures matching their respective system APIs, so that once you’re ready to make iOS 15 your minimum deployment target, you should be able to simply unlink AsyncCompatibilityKit from your project without having to make any additional changes to your code base (besides removing all `import AsyncCompatibilityKit` statements). 13 | 14 | AsyncCompatibilityKit even marks all of its added APIs as deprecated when integrated into an iOS 15-based project, so that you’ll get a reminder that it’s no longer needed once you’re able to use the matching system APIs directly *(as of Xcode 13.2, no such deprecation warnings seem to show up within SwiftUI views, though)*. 15 | 16 | However, it’s important to point out that the implementations provided by AsyncCompatibilityKit might not perfectly match their system equivalents in terms of behavior, since those system implementations are closed-source and private to Apple. No reverse engineering was involved in writing this library. Instead, each of the included APIs are complete reimplementations of the system APIs that they’re intended to match. It’s therefore strongly recommended that you thoroughly test any code that uses these backported versions before deploying that code to production. 17 | 18 | To learn more about the techniques used to implement these backports, and Swift Concurrency in general, check out [Discover Concurrency over on Swift by Sundell](https://swiftbysundell.com/discover/concurrency). 19 | 20 | ## Installation 21 | 22 | AsyncCompatibilityKit is distributed using the [Swift Package Manager](https://swift.org/package-manager). To install it, use Xcode’s `File > Add Packages...` menu command to add it to your iOS app project. 23 | 24 | Then import AsyncCompatibilityKit wherever you’d like to use it: 25 | 26 | ```swift 27 | import AsyncCompatibilityKit 28 | ``` 29 | 30 | For more information on how to use the Swift Package Manager, check out [this article](https://www.swiftbysundell.com/articles/managing-dependencies-using-the-swift-package-manager), or [its official documentation](https://swift.org/package-manager). 31 | 32 | Please note that AsyncCompatibilityKit is not meant to be integrated into targets other than iOS app projects with a minimum deployment target of iOS 13 or above. It also requires Xcode 13.2 or later. 33 | 34 | ## Support and contributions 35 | 36 | AsyncCompatibilityKit has been made freely available to the entire Swift community under the very permissive [MIT license](LICENSE.md), but please note that it doesn’t come with any official support channels, such as GitHub issues, or Twitter/email-based support. So, before you start using AsyncCompatibilityKit within one of your projects, it’s highly recommended that you spend some time familiarizing yourself with its implementation, in case you’ll run into any issues that you’ll need to debug. 37 | 38 | If you’ve found a bug, documentation typo, or if you want to propose a performance improvement, then feel free to [open a Pull Request](https://github.com/JohnSundell/AsyncCompatibilityKit/compare) (even if it just contains a unit test that reproduces a given issue). While all sorts of fixes and tweaks are more than welcome, AsyncCompatibilityKit is meant to be a very small, focused library, and should only contain simple backports of async/await-based system APIs. So, if you’d like to add any significant new features to the library, then it’s recommended that you fork it, which will let you extend and customize it to fit your needs. 39 | 40 | Hope you’ll enjoy using AsyncCompatibilityKit! 41 | -------------------------------------------------------------------------------- /Sources/Publisher+Async.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * AsyncCompatibilityKit 3 | * Copyright (c) John Sundell 2021 4 | * MIT license, see LICENSE.md file for details 5 | */ 6 | 7 | import Combine 8 | 9 | @available(iOS, deprecated: 15.0, message: "AsyncCompatibilityKit is only useful when targeting iOS versions earlier than 15") 10 | public extension Publisher { 11 | /// Convert this publisher into an `AsyncThrowingStream` that 12 | /// can be iterated over asynchronously using `for try await`. 13 | /// The stream will yield each output value produced by the 14 | /// publisher and will finish once the publisher completes. 15 | var values: AsyncThrowingStream { 16 | AsyncThrowingStream { continuation in 17 | var cancellable: AnyCancellable? 18 | let onTermination = { cancellable?.cancel() } 19 | 20 | continuation.onTermination = { @Sendable _ in 21 | onTermination() 22 | } 23 | 24 | cancellable = sink( 25 | receiveCompletion: { completion in 26 | switch completion { 27 | case .finished: 28 | continuation.finish() 29 | case .failure(let error): 30 | continuation.finish(throwing: error) 31 | } 32 | }, receiveValue: { value in 33 | continuation.yield(value) 34 | } 35 | ) 36 | } 37 | } 38 | } 39 | 40 | @available(iOS, deprecated: 15.0, message: "AsyncCompatibilityKit is only useful when targeting iOS versions earlier than 15") 41 | public extension Publisher where Failure == Never { 42 | /// Convert this publisher into an `AsyncStream` that can 43 | /// be iterated over asynchronously using `for await`. The 44 | /// stream will yield each output value produced by the 45 | /// publisher and will finish once the publisher completes. 46 | var values: AsyncStream { 47 | AsyncStream { continuation in 48 | var cancellable: AnyCancellable? 49 | let onTermination = { cancellable?.cancel() } 50 | 51 | continuation.onTermination = { @Sendable _ in 52 | onTermination() 53 | } 54 | 55 | cancellable = sink( 56 | receiveCompletion: { _ in 57 | continuation.finish() 58 | }, receiveValue: { value in 59 | continuation.yield(value) 60 | } 61 | ) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/URLSession+Async.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * AsyncCompatibilityKit 3 | * Copyright (c) John Sundell 2021 4 | * MIT license, see LICENSE.md file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | @available(iOS, deprecated: 15.0, message: "AsyncCompatibilityKit is only useful when targeting iOS versions earlier than 15") 10 | public extension URLSession { 11 | /// Start a data task with a URL using async/await. 12 | /// - parameter url: The URL to send a request to. 13 | /// - returns: A tuple containing the binary `Data` that was downloaded, 14 | /// as well as a `URLResponse` representing the server's response. 15 | /// - throws: Any error encountered while performing the data task. 16 | func data(from url: URL) async throws -> (Data, URLResponse) { 17 | try await data(for: URLRequest(url: url)) 18 | } 19 | 20 | /// Start a data task with a `URLRequest` using async/await. 21 | /// - parameter request: The `URLRequest` that the data task should perform. 22 | /// - returns: A tuple containing the binary `Data` that was downloaded, 23 | /// as well as a `URLResponse` representing the server's response. 24 | /// - throws: Any error encountered while performing the data task. 25 | func data(for request: URLRequest) async throws -> (Data, URLResponse) { 26 | var dataTask: URLSessionDataTask? 27 | let onCancel = { dataTask?.cancel() } 28 | 29 | return try await withTaskCancellationHandler( 30 | handler: { 31 | onCancel() 32 | }, 33 | operation: { 34 | try await withCheckedThrowingContinuation { continuation in 35 | dataTask = self.dataTask(with: request) { data, response, error in 36 | guard let data = data, let response = response else { 37 | let error = error ?? URLError(.badServerResponse) 38 | return continuation.resume(throwing: error) 39 | } 40 | 41 | continuation.resume(returning: (data, response)) 42 | } 43 | 44 | dataTask?.resume() 45 | } 46 | } 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/View+Async.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * AsyncCompatibilityKit 3 | * Copyright (c) John Sundell 2021 4 | * MIT license, see LICENSE.md file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | @available(iOS, deprecated: 15.0, message: "AsyncCompatibilityKit is only useful when targeting iOS versions earlier than 15") 10 | public extension View { 11 | /// Attach an async task to this view, which will be performed 12 | /// when the view first appears, and cancelled if the view 13 | /// disappears (or is removed from the view hierarchy). 14 | /// - parameter priority: Any explicit priority that the async 15 | /// task should have. 16 | /// - parameter action: The async action that the task should run. 17 | func task( 18 | priority: TaskPriority = .userInitiated, 19 | _ action: @escaping () async -> Void 20 | ) -> some View { 21 | modifier(TaskModifier( 22 | priority: priority, 23 | action: action 24 | )) 25 | } 26 | } 27 | 28 | private struct TaskModifier: ViewModifier { 29 | var priority: TaskPriority 30 | var action: () async -> Void 31 | 32 | @State private var task: Task? 33 | 34 | func body(content: Content) -> some View { 35 | content 36 | .onAppear { 37 | task = Task(priority: priority) { 38 | await action() 39 | } 40 | } 41 | .onDisappear { 42 | task?.cancel() 43 | task = nil 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Tests/PublisherTests.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * AsyncCompatibilityKit 3 | * Copyright (c) John Sundell 2021 4 | * MIT license, see LICENSE.md file for details 5 | */ 6 | 7 | import XCTest 8 | import Combine 9 | import AsyncCompatibilityKit 10 | 11 | final class PublisherTests: XCTestCase { 12 | func testValuesFromNonThrowingPublisher() async { 13 | let subject = PassthroughSubject() 14 | 15 | let valueTask = Task<[Int], Never> { 16 | var values = [Int]() 17 | 18 | for await value in subject.values { 19 | values.append(value) 20 | } 21 | 22 | return values 23 | } 24 | 25 | Task { 26 | subject.send(1) 27 | subject.send(2) 28 | subject.send(3) 29 | subject.send(completion: .finished) 30 | } 31 | 32 | let values = await valueTask.value 33 | XCTAssertEqual(values, [1, 2, 3]) 34 | } 35 | 36 | func testValuesFromThrowingPublisher() async throws { 37 | let subject = PassthroughSubject() 38 | 39 | let valueTask = Task<[Int], Error> { 40 | var values = [Int]() 41 | 42 | for try await value in subject.values { 43 | values.append(value) 44 | } 45 | 46 | return values 47 | } 48 | 49 | Task { 50 | subject.send(1) 51 | subject.send(2) 52 | subject.send(3) 53 | subject.send(completion: .finished) 54 | } 55 | 56 | let values = try await valueTask.value 57 | XCTAssertEqual(values, [1, 2, 3]) 58 | } 59 | 60 | func testValuesFromThrowingPublisherThatThrowsError() async throws { 61 | let subject = PassthroughSubject() 62 | let error = URLError(.cancelled) 63 | var values = [Int]() 64 | let valueHandler = { values.append($0) } 65 | 66 | let valueTask = Task { 67 | for try await value in subject.values { 68 | valueHandler(value) 69 | } 70 | } 71 | 72 | Task { 73 | subject.send(1) 74 | subject.send(2) 75 | subject.send(3) 76 | subject.send(completion: .failure(error)) 77 | } 78 | 79 | do { 80 | try await valueTask.value 81 | XCTFail("Expected error to be thrown") 82 | } catch error as URLError { 83 | XCTAssertEqual(error.code, .cancelled) 84 | XCTAssertEqual(values, [1, 2, 3]) 85 | } catch { 86 | XCTFail("Invalid error thrown: \(error)") 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Tests/URLSessionTests.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * AsyncCompatibilityKit 3 | * Copyright (c) John Sundell 2021 4 | * MIT license, see LICENSE.md file for details 5 | */ 6 | 7 | import XCTest 8 | import AsyncCompatibilityKit 9 | 10 | final class URLSessionTests: XCTestCase { 11 | private let session = URLSession.shared 12 | private let fileContents = "Hello, world!" 13 | private var fileURL: URL! 14 | 15 | override func setUpWithError() throws { 16 | try super.setUpWithError() 17 | 18 | let fileName = "AsyncCompatibilityKitTests-" + UUID().uuidString 19 | fileURL = URL(fileURLWithPath: NSTemporaryDirectory() + fileName) 20 | try Data(fileContents.utf8).write(to: fileURL) 21 | } 22 | 23 | override func tearDown() { 24 | super.tearDown() 25 | try? FileManager.default.removeItem(at: fileURL) 26 | } 27 | 28 | func testDataFromURLWithoutError() async throws { 29 | let (data, response) = try await session.data(from: fileURL) 30 | let string = String(decoding: data, as: UTF8.self) 31 | 32 | XCTAssertEqual(string, fileContents) 33 | XCTAssertEqual(response.url, fileURL) 34 | } 35 | 36 | func testDataFromURLThatThrowsError() async { 37 | let invalidURL = fileURL.appendingPathComponent("doesNotExist") 38 | 39 | do { 40 | _ = try await session.data(from: invalidURL) 41 | XCTFail("Expected error to be thrown") 42 | } catch { 43 | verifyThatError(error, containsURL: invalidURL) 44 | } 45 | } 46 | 47 | func testDataWithURLRequestWithoutError() async throws { 48 | let request = URLRequest(url: fileURL) 49 | let (data, response) = try await session.data(for: request) 50 | let string = String(decoding: data, as: UTF8.self) 51 | 52 | XCTAssertEqual(string, fileContents) 53 | XCTAssertEqual(response.url, fileURL) 54 | } 55 | 56 | func testDataWithURLRequestThatThrowsError() async { 57 | let invalidURL = fileURL.appendingPathComponent("doesNotExist") 58 | let request = URLRequest(url: invalidURL) 59 | 60 | do { 61 | _ = try await session.data(for: request) 62 | XCTFail("Expected error to be thrown") 63 | } catch { 64 | verifyThatError(error, containsURL: invalidURL) 65 | } 66 | } 67 | 68 | func testCancellingParentTaskCancelsDataTask() async throws { 69 | let task = Task { try await session.data(from: fileURL) } 70 | Task { task.cancel() } 71 | 72 | do { 73 | _ = try await task.value 74 | XCTFail("Expected error to be thrown") 75 | } catch let error as URLError { 76 | verifyThatError(error, containsURL: fileURL) 77 | XCTAssertEqual(error.code, .cancelled) 78 | } catch { 79 | XCTFail("Invalid error thrown: \(error)") 80 | } 81 | } 82 | } 83 | 84 | private extension URLSessionTests { 85 | func verifyThatError(_ error: Error, containsURL url: URL) { 86 | // We don't want to make too many assumptions about the 87 | // errors that URLSession throws, so we simply verify that 88 | // the thrown error's description contains the URL in question: 89 | XCTAssertTrue("\(error)".contains(url.absoluteString)) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Tests/ViewTests.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * AsyncCompatibilityKit 3 | * Copyright (c) John Sundell 2021 4 | * MIT license, see LICENSE.md file for details 5 | */ 6 | 7 | import XCTest 8 | import Combine 9 | import SwiftUI 10 | import AsyncCompatibilityKit 11 | 12 | final class ViewTests: XCTestCase { 13 | private var cancellables: Set! 14 | 15 | override func setUp() { 16 | super.setUp() 17 | cancellables = [] 18 | } 19 | 20 | func testTaskIsStartedWhenViewAppears() { 21 | var taskWasStarted = false 22 | let expectation = self.expectation(description: #function) 23 | 24 | let startTask = { 25 | taskWasStarted = true 26 | expectation.fulfill() 27 | } 28 | 29 | let view = Color.clear.task { 30 | startTask() 31 | } 32 | 33 | showView(view) 34 | 35 | waitForExpectations(timeout: 1) 36 | XCTAssertTrue(taskWasStarted) 37 | } 38 | 39 | func testTaskIsCancelledWhenViewDisappears() { 40 | class Coordinator: ObservableObject { 41 | @Published private(set) var showTaskView = true 42 | @Published private(set) var taskViewDidUpdate = false 43 | @Published var taskError: Error? 44 | 45 | func updateTaskViewAfterDelay() { 46 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { 47 | self.taskViewDidUpdate = true 48 | } 49 | } 50 | 51 | func hideTaskViewAfterDelay() { 52 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 53 | self.showTaskView = false 54 | } 55 | } 56 | } 57 | 58 | struct TestView: View { 59 | @ObservedObject var coordinator: Coordinator 60 | 61 | var body: some View { 62 | if coordinator.showTaskView { 63 | Text(coordinator.taskViewDidUpdate ? "Updated" : "Not updated") 64 | .task { 65 | do { 66 | // Make the task wait for 10 seconds, which will 67 | // throw an error if the task was cancelled in the 68 | // meantime (which is what we're expecting): 69 | try await Task.sleep(nanoseconds: 10_000_000_000) 70 | } catch { 71 | coordinator.taskError = error 72 | } 73 | } 74 | .onAppear { 75 | coordinator.updateTaskViewAfterDelay() 76 | coordinator.hideTaskViewAfterDelay() 77 | } 78 | } 79 | } 80 | } 81 | 82 | let coordinator = Coordinator() 83 | let view = TestView(coordinator: coordinator) 84 | showView(view) 85 | 86 | let expectation = self.expectation(description: #function) 87 | var thrownError: Error? 88 | 89 | coordinator 90 | .$taskError 91 | .dropFirst() 92 | .sink { error in 93 | thrownError = error 94 | expectation.fulfill() 95 | } 96 | .store(in: &cancellables) 97 | 98 | waitForExpectations(timeout: 10) 99 | XCTAssertTrue(thrownError is CancellationError) 100 | } 101 | } 102 | 103 | private extension ViewTests { 104 | func showView(_ view: T) { 105 | let window = UIWindow(frame: UIScreen.main.bounds) 106 | window.rootViewController = UIHostingController(rootView: view) 107 | window.makeKeyAndVisible() 108 | } 109 | } 110 | --------------------------------------------------------------------------------