├── .gitignore
├── .spi.yml
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── LICENSE
├── Package.resolved
├── Package.swift
├── README.md
├── Sources
└── bluetooth-law-energy-swift
│ ├── BluetoothLEManager.swift
│ ├── Combine+
│ └── Publisher+.swift
│ ├── delegate
│ ├── BluetoothDelegate.swift
│ └── PeripheralDelegate.swift
│ ├── enum
│ ├── Errors.swift
│ └── ServiceType.swift
│ ├── ext
│ ├── CBPeripheral+.swift
│ └── Task+.swift
│ ├── model
│ └── BLEState.swift
│ ├── protocol
│ ├── IBluetoothLEManager.swift
│ └── ILogger.swift
│ └── service
│ ├── AppleLogger.swift
│ ├── CacheServices.swift
│ ├── ServiceRegistration.swift
│ ├── State.swift
│ ├── StreamFactory.swift
│ └── StreamRegistration.swift
├── Tests
└── bluetooth-law-energy-swiftTests
│ └── BluetoothLEManagerTests.swift
└── img
├── ble_discover.gif
├── ble_flow.png
├── ble_mac.png
└── ble_manager.jpeg
/.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: [bluetooth-law-energy-swift]
5 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Igor Shelopaev
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "retry-policy-service",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/swiftuiux/retry-policy-service.git",
7 | "state" : {
8 | "revision" : "2a6a1f057fbf77337dfc73db98bd3d538127b3e2",
9 | "version" : "1.0.1"
10 | }
11 | }
12 | ],
13 | "version" : 2
14 | }
15 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.7
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "bluetooth-law-energy-swift",
8 | platforms: [.macOS(.v12), .iOS(.v15), .watchOS(.v8), .tvOS(.v15)],
9 | products: [
10 | // Products define the executables and libraries a package produces, making them visible to other packages.
11 | .library(
12 | name: "bluetooth-law-energy-swift",
13 | targets: ["bluetooth-law-energy-swift"]),
14 | ],
15 | dependencies: [
16 | // Dependencies declare other packages that this package depends on.
17 | .package(url: "https://github.com/swiftuiux/retry-policy-service.git", from: "1.0.1"),
18 | ],
19 | targets: [
20 | // Targets are the basic building blocks of a package, defining a module or a test suite.
21 | // Targets can depend on other targets in this package and products from dependencies.
22 | .target(
23 | name: "bluetooth-law-energy-swift",
24 | dependencies: [
25 | .product(name: "retry-policy-service", package: "retry-policy-service"),
26 | ]),
27 | .testTarget(
28 | name: "bluetooth-law-energy-swiftTests",
29 | dependencies: ["bluetooth-law-energy-swift"]),
30 | ]
31 | )
32 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## ⭐ Star it — so I know it’s worth to keep improving it.
2 |
3 | # BLE Asynchronous Bluetooth Low Energy Kit
4 |
5 | [](https://swiftpackageindex.com/swiftuiux/bluetooth-law-energy-swift) [](https://swiftpackageindex.com/swiftuiux/bluetooth-law-energy-swift)
6 |
7 | ## [Swiftui example](https://github.com/swiftuiux/ble-bluetooth-law-energy-swiftui-example-ios)
8 |
9 |
10 | ## [Documentation(API)](https://swiftpackageindex.com/swiftuiux/bluetooth-law-energy-swift/main/documentation/bluetooth_law_energy_swift)
11 |
12 | 
13 |
14 | ## Main Features
15 |
16 | | Feature | Description |
17 | |---------|-------------|
18 | | **Bluetooth Authorization Management** | Manage and request Bluetooth authorization permissions from the user. |
19 | | **Bluetooth Power Management** | Monitor and handle Bluetooth power state changes to ensure functionality. |
20 | | **State Publishing** | Publish the current state of the Bluetooth manager, including authorization and power status. |
21 | | **User Interface Integration** | Integrate seamlessly with user interfaces to provide real-time updates on Bluetooth status and devices. |
22 | | **Peripheral Management** | Manage discovered Bluetooth peripherals, including connection and disconnection. |
23 | | **Multi-platform** | Support for multiple platforms, ensuring compatibility across different devices and operating systems. |
24 | | **Utilizing Modern Concurrency in Swift with Async Stream** | Employ modern concurrency techniques in Swift, such as AsyncStream, for efficient and responsive Bluetooth operations. |
25 | | **Scanning of Available Devices** | Scan for and discover available Bluetooth devices in the vicinity. |
26 | | **Fetching Services for Discovered Devices Asynchronously** | Fetch and manage services for discovered Bluetooth devices using asynchronous methods, ensuring smooth and non-blocking operations. |
27 |
28 | ## Typical Workflow for Discovering Characteristics on a Peripheral
29 |
30 | The following blocks show a workflow for using CBCentralManager. The flowchart provides a visualization of the key steps involved in managing Bluetooth Low Energy devices and gives you a theoretical basis to get started.
31 |
32 | 
33 |
34 |
35 | ## Bluetooth LE Manager Implementation Specifics
36 |
37 | `BluetoothLEManager` serves as a wrapper around `CBCentralManager`, providing a streamlined interface for managing Bluetooth Low Energy (BLE) operations. This package integrates the authorization and power state monitoring specific to Apple's implementation for BLE devices, which simplifies handling these aspects within your application.
38 |
39 | ### Key Implementation Details
40 |
41 | 1. **Authorization and Power State Management**:
42 | - When a `CBCentralManager` is instantiated, it automatically prompts the user for Bluetooth authorization if it hasn't been granted yet. This prompt is managed by the system, and no additional code is needed to request authorization.
43 | - If Bluetooth is turned off during the lifecycle of `BluetoothLEManager`, the manager will handle this state change and update the relevant properties (`isAuthorized`, `isPowered`, etc.) accordingly. This ensures that your application remains informed about the Bluetooth state throughout its lifecycle.
44 |
45 | 2. **Handling Mid-Lifecycle Bluetooth State Changes**:
46 | - If Bluetooth is turned off or access is denied while `BluetoothLEManager` is active, the manager will process this change and provide the necessary updates to the state properties. This allows your application to respond to changes in Bluetooth availability dynamically.
47 |
48 | 3. **User Authorization Handling**:
49 | - When a `CBCentralManager` is created, it prompts the user for authorization if it hasn't been granted yet. If the user denies this request, `BluetoothLEManager` will detect this and update its `isAuthorized` property accordingly.
50 | - If authorization is denied, the manager can inform the user and suggest that they enable Bluetooth access in the device settings. This ensures that your application can guide users to resolve authorization issues without requiring additional implementation.
51 |
52 | 4. **Dynamic Scanning Based on Subscribers**:
53 | - `BluetoothLEManager` manages the scanning process based on the number of active subscribers waiting the peripheral list.
54 | - Scanning for peripherals starts only when at least one subscriber is connected through the `peripheralsStream` method to get the list of peripherals. This ensures that scanning is active when there is a need for peripheral data.
55 | - When the number of subscribers drops to zero, the manager stops scanning to conserve resources and battery life. This allows efficient use of the device's Bluetooth capabilities.
56 | 5. **Specifics of Authorizing Access to Bluetooth and Checking Availability for macOS**:
57 | - Detailed guidance on these aspects can be found [here](https://github.com/swiftuiux/bluetooth-law-energy-swiftui-example).
58 |
59 | ## Public API
60 |
61 | | Name | Type | Description | Type/Return Type |
62 | |---------------------|----------|-------------------------------------------------------------------------------------------------------------------------|-------------------------------------|
63 | | `bleState` | Property | A subject that publishes the BLE state changes. | `CurrentValueSubject` |
64 | | `peripheralsStream` | Property | Provides an asynchronous stream of discovered Bluetooth peripherals. | `AsyncStream<[CBPeripheral]>` |
65 | | `discoverServices` | Method | Fetches services for a given peripheral, with optional caching and optional disconnection. | `async throws -> [CBService]` |
66 | | `connect` | Method | Connects to a specific peripheral. 🟡 Always use the same BluetoothLEManager instance to manage connections and disconnections for a peripheral to avoid errors and ensure correct behavior. | `async throws -> Void` |
67 | | `disconnect` | Method | Disconnects from a specific peripheral. | `async throws -> Void` |
68 |
69 | Apple’s documentation specifies that all Core Bluetooth interactions should be performed on the main thread to maintain thread safety and proper synchronization of Bluetooth events. This includes interactions with CBCentralManager, such as connecting and disconnecting peripherals.
70 |
71 | ### BLEState
72 |
73 | The `BLEState` struct provides information about the current state of Bluetooth on the device. This struct includes three key properties that indicate whether Bluetooth is authorized, powered on, and actively scanning for peripherals.
74 |
75 | #### Properties
76 |
77 | | Name | Type | Description |
78 | |----------------|--------|-----------------------------------------------------------------------------|
79 | | `isAuthorized` | Bool | Indicates if Bluetooth is authorized (`true` if authorized, `false` otherwise). |
80 | | `isPowered` | Bool | Indicates if Bluetooth is powered on (`true` if powered, `false` otherwise). |
81 | | `isScanning` | Bool | Indicates if Bluetooth is currently scanning for peripherals (`true` if scanning, `false` otherwise). |
82 |
83 |
84 | ### Description of `IBluetoothLEManager` Protocol
85 |
86 | The `IBluetoothLEManager` protocol defines the essential functionalities for managing Bluetooth Low Energy (BLE) operations. It includes properties and methods for monitoring the state of Bluetooth, discovering peripherals, and fetching services for a specific peripheral. This protocol is intended to be implemented by classes or structures that handle Bluetooth communication on macOS 12.0+, iOS 15.0+, tvOS 15.0+, and watchOS 8.0+.
87 |
88 |
89 | | iOS | macOS |
90 | |------|-------|
91 | |  |  |
92 |
93 | ## License
94 |
95 | This project is licensed under the MIT License.
96 |
--------------------------------------------------------------------------------
/Sources/bluetooth-law-energy-swift/BluetoothLEManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BluetoothLEManager.swift
3 | //
4 | //
5 | // Created by Igor on 12.07.24.
6 | //
7 |
8 | import Combine
9 | import CoreBluetooth
10 | import retry_policy_service
11 |
12 | /// Manages Bluetooth Low Energy (BLE) interactions using Combine and CoreBluetooth.
13 | @available(macOS 12, iOS 15, tvOS 15.0, watchOS 8.0, *)
14 | public final class BluetoothLEManager: NSObject, ObservableObject, IBluetoothLEManager {
15 |
16 | /// Publishes BLE state changes to the main actor.
17 | @MainActor
18 | public let bleState: CurrentValueSubject = .init(.init())
19 |
20 | /// Internal state flags.
21 | private var isAuthorized = false
22 | private var isPowered = false
23 | private var isScanning = false
24 |
25 | /// Type aliases for publishers.
26 | private typealias StatePublisher = AnyPublisher
27 | private typealias PeripheralPublisher = AnyPublisher<[CBPeripheral], Never>
28 |
29 | /// Publishers for state and peripheral updates.
30 | private var getStatePublisher: StatePublisher { delegateHandler.statePublisher }
31 | private var getPeripheralPublisher: PeripheralPublisher { delegateHandler.peripheralPublisher }
32 |
33 | /// CoreBluetooth central manager.
34 | private let centralManager: CBCentralManager
35 | /// The queue on which the central delivers callbacks; we also dispatch central calls onto this queue.
36 | private let bleQueue: DispatchQueue
37 |
38 | @MainActor
39 | private let cachedServices = CacheServices()
40 |
41 | private typealias Delegate = BluetoothDelegate
42 | private let stream : StreamFactory
43 | private let delegateHandler: Delegate
44 | private var cancellables: Set = []
45 | private let retry = RetryService(strategy: .exponential(retry: 5, multiplier: 2, duration: .seconds(3), timeout: .seconds(15)))
46 | private let logger: ILogger
47 |
48 | /// Initializes the BluetoothLEManager with a logger.
49 | /// Always uses the main queue for CoreBluetooth to ensure thread safety and consistent behavior.
50 | public init(logger: ILogger?) {
51 | let logger = logger ?? AppleLogger(subsystem: "BluetoothLEManager", category: "Bluetooth")
52 | self.logger = logger
53 | stream = StreamFactory(logger: logger)
54 | delegateHandler = Delegate(logger: logger)
55 |
56 | // Always main queue — CoreBluetooth interactions are guaranteed to run here.
57 | self.bleQueue = .main
58 | self.centralManager = CBCentralManager(delegate: delegateHandler, queue: bleQueue)
59 |
60 | super.init()
61 | setupSubscriptions()
62 | }
63 |
64 | /// Deinitializes the BluetoothLEManager.
65 | deinit {
66 | onBLE { [centralManager] in
67 | centralManager.stopScan()
68 | centralManager.delegate = nil
69 | }
70 | logger.log("BluetoothManager deinitialized", level: .debug)
71 | }
72 |
73 | // MARK: - Queue helper
74 |
75 | @inline(__always)
76 | private func onBLE(_ block: @escaping () -> Void) {
77 | bleQueue.async(execute: block) // always hop; simpler & safe
78 | }
79 |
80 | // MARK: - API
81 |
82 | /// Provides a stream of discovered peripherals.
83 | @MainActor
84 | public var peripheralsStream: AsyncStream<[CBPeripheral]> {
85 | get async{
86 | await stream.peripheralsStream()
87 | }
88 | }
89 |
90 | /// Discovers services for a given peripheral, with optional caching and optional disconnection.
91 | ///
92 | /// Note: public API stays on @MainActor; CoreBluetooth calls are dispatched to the central's queue.
93 | @MainActor
94 | public func discoverServices(for peripheral: CBPeripheral, from cache: Bool = true, disconnect: Bool = true) async throws -> [CBService] {
95 |
96 | try Task.checkCancellation() // ensure early exit if cancelled
97 | defer {
98 | if disconnect {
99 | // Ensure cancellation runs on the central's queue.
100 | onBLE { [centralManager] in
101 | centralManager.cancelPeripheralConnection(peripheral)
102 | }
103 | }
104 | }
105 |
106 | // Check the cache before attempting to fetch services.
107 | if cache, let services = cachedServices.fetch(for: peripheral) {
108 | return services
109 | }
110 |
111 | for delay in retry {
112 | do {
113 | return try await attemptFetchServices(for: peripheral, cache: cache)
114 | } catch { }
115 |
116 | try Task.checkCancellation()
117 | try await Task.sleep(nanoseconds: delay)
118 |
119 | if cache, let services = cachedServices.fetch(for: peripheral) {
120 | return services
121 | }
122 | }
123 |
124 | // Final attempt to fetch services if retries fail
125 | return try await attemptFetchServices(for: peripheral, cache: cache)
126 | }
127 |
128 | /// Connects to a specific peripheral.
129 | /// Contract:
130 | /// - Public API is @MainActor for UI-facing consistency.
131 | /// - We *register* an async expectation with `delegateHandler` first,
132 | /// then invoke CoreBluetooth on the central's queue via `onBLE`.
133 | /// - Cancellation is checked early and again right before the central call,
134 | /// so the operation can exit cleanly if the task was cancelled.
135 | @MainActor
136 | public func connect(to peripheral: CBPeripheral) async throws {
137 | guard peripheral.isNotConnected else { throw Errors.connected(peripheral) }
138 | try Task.checkCancellation()
139 |
140 | return try await withCheckedThrowingContinuation { continuation in
141 | Task {
142 | let id = peripheral.getId
143 | let name = peripheral.getName
144 | do {
145 | try await delegateHandler.connect(to: id, name: name, with: continuation)
146 | try Task.checkCancellation() // ensure early exit if cancelled
147 | onBLE { [weak self] in
148 | guard let self else { return }
149 | self.centralManager.connect(peripheral, options: nil)
150 | }
151 | } catch {
152 | continuation.resume(throwing: error)
153 | }
154 | }
155 | }
156 | }
157 |
158 | /// Disconnects from a specific peripheral.
159 | /// Contract:
160 | /// - Public API is @MainActor for UI-facing consistency.
161 | /// - We *register* an async expectation with `delegateHandler` first,
162 | /// then invoke CoreBluetooth on the central's queue via `onBLE`.
163 | /// - Cancellation is checked early and again right before the central call,
164 | /// so the operation can exit cleanly if the task was cancelled.
165 | @MainActor
166 | public func disconnect(from peripheral: CBPeripheral) async throws {
167 | guard peripheral.isConnected else { throw Errors.notConnected(peripheral.getName) }
168 | try Task.checkCancellation()
169 |
170 | return try await withCheckedThrowingContinuation { continuation in
171 | Task {
172 | let id = peripheral.getId
173 | let name = peripheral.getName
174 | do {
175 | try await delegateHandler.disconnect(to: id, name: name, with: continuation)
176 | try Task.checkCancellation() // ensure early exit if cancelled
177 | onBLE { [weak self] in
178 | guard let self else { return }
179 | self.centralManager.cancelPeripheralConnection(peripheral)
180 | }
181 | } catch {
182 | continuation.resume(throwing: error)
183 | }
184 | }
185 | }
186 | }
187 |
188 | // MARK: - Private Methods
189 |
190 | /// Attempts to connect to the given peripheral and fetch its services.
191 | @MainActor
192 | private func attemptFetchServices(for peripheral: CBPeripheral, cache: Bool) async throws -> [CBService] {
193 | try Task.checkCancellation() // ensure early exit if cancelled
194 | try await connect(to: peripheral)
195 | return try await discover(for: peripheral, cache: cache)
196 | }
197 |
198 | /// Discovers services for a connected peripheral.
199 | @MainActor
200 | private func discover(for peripheral: CBPeripheral, cache: Bool) async throws -> [CBService] {
201 | defer { peripheral.delegate = nil }
202 |
203 | try Task.checkCancellation()
204 |
205 | let delegate = PeripheralDelegate(logger: logger)
206 | peripheral.delegate = delegate
207 | try await delegate.discoverServices(for: peripheral)
208 |
209 | let services = peripheral.services ?? []
210 |
211 | if cache {
212 | cachedServices.add(key: peripheral.getId, services: services)
213 | }
214 |
215 | return services
216 | }
217 |
218 | /// Sets up Combine subscriptions for state and peripheral changes.
219 | private func setupSubscriptions() {
220 | getPeripheralPublisher
221 | .sink { [weak self] peripherals in
222 | guard let self = self else { return }
223 | let stream = self.stream
224 | Task {
225 | await stream.updatePeripherals(peripherals)
226 | }
227 | }
228 | .store(in: &cancellables)
229 |
230 | Publishers.CombineLatest(getStatePublisher, stream.subscriberCountPublisher)
231 | .receiveOnMainAndEraseToAnyPublisher()
232 | .sink { [weak self] state, subscriberCount in
233 | guard let self = self else { return }
234 | let result = self.checkForScan(state, subscriberCount)
235 | let bleState = self.bleState
236 | Task { @MainActor in
237 | bleState.send(result)
238 | }
239 | }
240 | .store(in: &cancellables)
241 | }
242 |
243 | /// Checks if Bluetooth is ready (powered on and authorized).
244 | private var checkIfBluetoothReady: Bool {
245 | isAuthorized = State.isBluetoothAuthorized
246 | isPowered = State.isBluetoothPoweredOn(for: centralManager)
247 | return isPowered && isAuthorized
248 | }
249 |
250 | /// Starts or stops scanning based on the state and subscriber count.
251 | private func checkForScan(_ state: CBManagerState, _ subscriberCount: Int) -> BLEState {
252 | if !checkIfBluetoothReady {
253 | stopScanning()
254 | } else {
255 | if subscriberCount == 0 {
256 | stopScanning()
257 | } else {
258 | startScanning()
259 | }
260 | isScanning = subscriberCount != 0
261 | }
262 |
263 | return .init(
264 | isAuthorized: self.isAuthorized,
265 | isPowered: self.isPowered,
266 | isScanning: self.isScanning
267 | )
268 | }
269 |
270 | /// Starts scanning for peripherals (dispatched to the central's queue).
271 | private func startScanning() {
272 | onBLE { [centralManager] in
273 | centralManager.scanForPeripherals(withServices: nil, options: nil)
274 | }
275 | }
276 |
277 | /// Stops scanning for peripherals (dispatched to the central's queue).
278 | private func stopScanning() {
279 | onBLE { [centralManager] in
280 | centralManager.stopScan()
281 | }
282 | }
283 | }
284 |
--------------------------------------------------------------------------------
/Sources/bluetooth-law-energy-swift/Combine+/Publisher+.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Publisher+.swift
3 | //
4 | //
5 | // Created by Igor on 12.07.24.
6 | //
7 |
8 | import Combine
9 | import CoreBluetooth
10 |
11 | /// Extension to the Publisher type where the Output is CBManagerState and Failure is Never
12 | extension Publisher where Output == CBManagerState, Failure == Never {
13 |
14 | /// Custom operator to drop the first state if it is .poweredOff
15 | ///
16 | /// - Returns: An `AnyPublisher` that drops the first state if it is .poweredOff
17 | func dropFirstIfPoweredOff() -> AnyPublisher {
18 | // Accumulates state with a count
19 | self.scan((0, CBManagerState.unknown)) { acc, newState in
20 | let (count, _) = acc
21 | // Increment count and update state
22 | return (count + 1, newState)
23 | }
24 | // Drop the first state if it is .poweredOff
25 | .drop { (count, state) in
26 | return count == 1 && state == .poweredOff
27 | }
28 | // Extract the actual CBManagerState value from the tuple
29 | .map { $0.1 }
30 | // Erase to AnyPublisher to hide the implementation details
31 | .eraseToAnyPublisher()
32 | }
33 | }
34 |
35 | /// Extension to the Publisher type for receiving on the main queue and erasing to AnyPublisher
36 | extension Publisher {
37 |
38 | /// Custom operator to receive output on the main thread and erase to AnyPublisher
39 | ///
40 | /// - Returns: An `AnyPublisher` that ensures the output is received on the main thread
41 | func receiveOnMainAndEraseToAnyPublisher() -> AnyPublisher