Skip to content

Commit 71ef5b0

Browse files
authored
[Feature] Partial strict concurrency support (#56)
* Add unchecked sendable to required types * Remove unchecked from most sendables. * Fix Peripheral * Go after central manager * Update the docs * Combine doesn't need preconcurrency anymore * Remove comment
1 parent 0f0203d commit 71ef5b0

File tree

10 files changed

+69
-48
lines changed

10 files changed

+69
-48
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ try await centralManager.connect(peripheral, options: nil)
4545
The central manager publishes several events. You can subscribe to them by using the `eventPublisher`.
4646

4747
```swift
48-
centralManager.eventPublisher
48+
await centralManager.eventPublisher
4949
.sink {
5050
switch $0 {
5151
case .didConnectPeripheral(let peripheral):
@@ -92,7 +92,7 @@ To get notified when a characteristic's value is updated, we provide a publisher
9292

9393
```swift
9494
let characteristicUUID = CBUUID()
95-
peripheral.characteristicValueUpdatedPublisher
95+
await peripheral.characteristicValueUpdatedPublisher
9696
.filter { $0.uuid == characteristicUUID }
9797
.map { try? $0.parsedValue() as String? } // replace `String?` with your type
9898
.sink { value in
@@ -129,7 +129,7 @@ fetchTask.cancel()
129129
There might also be cases were you want to stop awaiting for all responses. For example, when bluetooth has been powered off. This can be done like so:
130130

131131
```swift
132-
centralManager.eventPublisher
132+
await centralManager.eventPublisher
133133
.sink {
134134
switch $0 {
135135
case .didUpdateState(let state):

Sources/CentralManager/CentralManager.swift

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
// Copyright (c) 2021 Manuel Fernandez-Peix Perez. All rights reserved.
22

33
import Foundation
4-
import CoreBluetooth
4+
@preconcurrency import CoreBluetooth
55
import Combine
66
import os.log
77

88
/// An object that scans for, discovers, connects to, and manages peripherals using concurrency.
9-
public class CentralManager {
9+
public final class CentralManager: Sendable {
1010

1111
private typealias Utils = CentralManagerUtils
1212

@@ -27,12 +27,16 @@ public class CentralManager {
2727
}
2828

2929
public var isScanning: Bool {
30-
self.context.isScanning
30+
get async {
31+
await self.context.isScanning
32+
}
3133
}
3234

33-
public lazy var eventPublisher: AnyPublisher<CentralManagerEvent, Never> = {
34-
self.context.eventSubject.eraseToAnyPublisher()
35-
}()
35+
public var eventPublisher: AnyPublisher<CentralManagerEvent, Never> {
36+
get async {
37+
await self.context.eventSubject.eraseToAnyPublisher()
38+
}
39+
}
3640

3741
private let cbCentralManager: CBCentralManager
3842
private let context: CentralManagerContext
@@ -58,7 +62,7 @@ public class CentralManager {
5862
try await self.context.waitUntilReadyExecutor.enqueue { [weak self] in
5963
// Note we need to check again here in case the Bluetooth state was updated after we last
6064
// checked but before the work was enqueued. Otherwise we could wait indefinitely.
61-
guard let self = self, let isBluetoothReadyResult = Utils.isBluetoothReady(self.bluetoothState) else {
65+
guard let self = self, let isBluetoothReadyResult = Utils.isBluetoothReady(self.cbCentralManager.state) else {
6266
return
6367
}
6468
Task {
@@ -79,7 +83,7 @@ public class CentralManager {
7983
/// Scans for peripherals that are advertising services.
8084
public func scanForPeripherals(
8185
withServices serviceUUIDs: [CBUUID]?,
82-
options: [String : Any]? = nil
86+
options: [String : any Sendable]? = nil
8387
) async throws -> AsyncStream<ScanData> {
8488
try await withCheckedThrowingContinuation { continuation in
8589
Task {
@@ -123,7 +127,7 @@ public class CentralManager {
123127
}
124128

125129
/// Establishes a local connection to a peripheral.
126-
public func connect(_ peripheral: Peripheral, options: [String : Any]? = nil) async throws {
130+
public func connect(_ peripheral: Peripheral, options: [String : any Sendable]? = nil) async throws {
127131
guard await !self.context.connectToPeripheralExecutor.hasWorkForKey(peripheral.identifier) else {
128132
Self.logger.error("Unable to connect to \(peripheral.identifier) because a connection attempt is already in progress")
129133

@@ -181,7 +185,7 @@ public class CentralManager {
181185
/// Cancels all pending operations, stops scanning and awaiting for any responses.
182186
/// - Note: Operation for Peripherals will not be cancelled. To do that, call `cancelAllOperations()` on the `Peripheral`.
183187
public func cancelAllOperations() async throws {
184-
if isScanning {
188+
if await isScanning {
185189
await self.stopScan()
186190
}
187191
try await self.context.flush(error: BluetoothError.operationCancelled)
@@ -237,9 +241,7 @@ extension CentralManager.DelegateWrapper: CBCentralManagerDelegate {
237241

238242
func centralManagerDidUpdateState(_ central: CBCentralManager) {
239243
Task {
240-
defer {
241-
self.context.eventSubject.send(.didUpdateState(state: central.state))
242-
}
244+
await self.context.eventSubject.send(.didUpdateState(state: central.state))
243245

244246
guard let isBluetoothReadyResult = Utils.isBluetoothReady(central.state) else { return }
245247

@@ -248,7 +250,9 @@ extension CentralManager.DelegateWrapper: CBCentralManagerDelegate {
248250
}
249251

250252
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) {
251-
self.context.eventSubject.send(.willRestoreState(state: dict))
253+
Task {
254+
await self.context.eventSubject.send(.willRestoreState(state: dict))
255+
}
252256
}
253257

254258
func centralManager(
@@ -265,7 +269,8 @@ extension CentralManager.DelegateWrapper: CBCentralManagerDelegate {
265269

266270
Task {
267271
guard let continuation = await self.context.scanForPeripheralsContext.continuation else {
268-
Self.logger.info("Ignoring peripheral '\(scanData.peripheral.name ?? "unknown", privacy: .private)' because the central manager is not scanning")
272+
let peripherlName = scanData.peripheral.name ?? "unknown"
273+
Self.logger.info("Ignoring peripheral '\(peripherlName, privacy: .private)' because the central manager is not scanning")
269274
return
270275
}
271276
continuation.yield(scanData)
@@ -286,7 +291,7 @@ extension CentralManager.DelegateWrapper: CBCentralManagerDelegate {
286291
Self.logger.info("Received onDidConnect without a continuation")
287292
}
288293

289-
self.context.eventSubject.send(
294+
await self.context.eventSubject.send(
290295
.didConnectPeripheral(peripheral: Peripheral(peripheral))
291296
)
292297
}
@@ -309,12 +314,14 @@ extension CentralManager.DelegateWrapper: CBCentralManagerDelegate {
309314
break
310315
}
311316

312-
self.context.eventSubject.send(
313-
.connectionEventDidOccur(
314-
connectionEvent: event,
315-
peripheral: peripheral
317+
Task {
318+
await self.context.eventSubject.send(
319+
.connectionEventDidOccur(
320+
connectionEvent: event,
321+
peripheral: peripheral
322+
)
316323
)
317-
)
324+
}
318325
}
319326
#endif
320327

@@ -356,7 +363,7 @@ extension CentralManager.DelegateWrapper: CBCentralManagerDelegate {
356363
Self.logger.info("Disconnected from \(peripheral.identifier) without a continuation")
357364
}
358365

359-
self.context.eventSubject.send(
366+
await self.context.eventSubject.send(
360367
.didDisconnectPeripheral(peripheral: Peripheral(peripheral), isReconnecting: isReconnecting, error: error)
361368
)
362369
}
@@ -378,7 +385,7 @@ extension CentralManager.DelegateWrapper: CBCentralManagerDelegate {
378385
Self.logger.info("Disconnected from \(peripheral.identifier) without a continuation")
379386
}
380387

381-
self.context.eventSubject.send(
388+
await self.context.eventSubject.send(
382389
.didDisconnectPeripheral(peripheral: Peripheral(peripheral), isReconnecting: false, error: error)
383390
)
384391
}

Sources/CentralManager/CentralManagerContext.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import CoreBluetooth
55
import Combine
66

77
/// Contains the objects necessary to track a Central Manager's commands.
8-
class CentralManagerContext {
8+
actor CentralManagerContext {
99
actor ScanForPeripheralsContext {
1010
let onContinuationChanged: (_ isScanning: Bool) -> Void
1111

@@ -25,7 +25,9 @@ class CentralManagerContext {
2525
private(set) var isScanning = false
2626

2727
private(set) lazy var scanForPeripheralsContext = ScanForPeripheralsContext { [weak self] isScanning in
28-
self?.isScanning = isScanning
28+
Task { [weak self] in
29+
await self?.updateIsScanning(isScanning)
30+
}
2931
}
3032

3133
private(set) lazy var eventSubject = PassthroughSubject<CentralManagerEvent, Never>()
@@ -61,4 +63,8 @@ class CentralManagerContext {
6163
try await flushableExecutor.flush(error: error)
6264
}
6365
}
66+
67+
private func updateIsScanning(_ isScanning: Bool) {
68+
self.isScanning = isScanning
69+
}
6470
}

Sources/CentralManager/ScanData.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import Foundation
44
import CoreBluetooth
55

66
/// Represents a single value gathered when scanning for peripheral.
7-
public struct ScanData {
7+
public struct ScanData: Sendable {
88
public let peripheral: Peripheral
99
/// A dictionary containing any advertisement and scan response data.
10-
public let advertisementData: [String : Any]
10+
public let advertisementData: [String : any Sendable]
1111
/// The current RSSI of the peripheral, in dBm. A value of 127 is reserved and indicates the RSSI
1212
/// was not available.
1313
public let rssi: NSNumber

Sources/Peripheral/Characteristic.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
// Copyright (c) 2021 Manuel Fernandez-Peix Perez. All rights reserved.
22

33
import Foundation
4-
import CoreBluetooth
4+
@preconcurrency import CoreBluetooth
55

66
/// A characteristic of a remote peripheral’s service.
77
/// - This class acts as a wrapper around `CBCharacteristic`.
8-
public struct Characteristic {
8+
public struct Characteristic: Sendable {
99
public let cbCharacteristic: CBCharacteristic
1010

1111
public init(_ cbCharacteristic: CBCharacteristic) {

Sources/Peripheral/Peripheral.swift

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
// Copyright (c) 2021 Manuel Fernandez-Peix Perez. All rights reserved.
22

33
import Foundation
4-
import CoreBluetooth
4+
@preconcurrency import CoreBluetooth
55
import Combine
66
import os.log
77

88
/// A remote peripheral device.
99
/// - This class acts as a wrapper around `CBPeripheral`.
10-
public class Peripheral {
10+
public final class Peripheral: Sendable {
1111

1212
private static var logger: Logger {
1313
Logging.logger(for: "peripheral")
1414
}
1515

1616
/// Publishes characteristics that are notifying of value changes.
17-
public lazy var characteristicValueUpdatedPublisher: AnyPublisher<Characteristic, Never> = {
18-
self.context.characteristicValueUpdatedSubject.eraseToAnyPublisher()
19-
}()
17+
public var characteristicValueUpdatedPublisher: AnyPublisher<Characteristic, Never> {
18+
get async {
19+
await self.context.characteristicValueUpdatedSubject.eraseToAnyPublisher()
20+
}
21+
}
2022

2123
/// The UUID associated with the peripheral.
2224
public var identifier: UUID {

Sources/Peripheral/PeripheralContext.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import CoreBluetooth
55
import Combine
66

77
/// Contains the objects necessary to track a Peripheral's commands.
8-
class PeripheralContext {
8+
actor PeripheralContext {
99
private(set) lazy var characteristicValueUpdatedSubject = PassthroughSubject<Characteristic, Never>()
1010
private(set) lazy var invalidatedServicesSubject = PassthroughSubject<[Service], Never>()
1111

Sources/Peripheral/PeripheralDelegate.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Foundation
44
import CoreBluetooth
55
import os.log
66

7-
class PeripheralDelegate: NSObject {
7+
final class PeripheralDelegate: NSObject, Sendable {
88

99
private static var logger: Logger {
1010
Logging.logger(for: "peripheralDelegate")
@@ -65,13 +65,11 @@ extension PeripheralDelegate: CBPeripheralDelegate {
6565
}
6666

6767
func peripheral(_ cbPeripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
68-
69-
if characteristic.isNotifying {
70-
// characteristic.value is Data() and it will get trampled if allowed to run async.
71-
self.context.characteristicValueUpdatedSubject.send( Characteristic(characteristic) )
72-
}
73-
7468
Task {
69+
if characteristic.isNotifying {
70+
await self.context.characteristicValueUpdatedSubject.send(Characteristic(characteristic))
71+
}
72+
7573
do {
7674
let result = CallbackUtils.result(for: (), error: error)
7775
try await self.context.readCharacteristicValueExecutor.setWorkCompletedForKey(
@@ -169,6 +167,8 @@ extension PeripheralDelegate: CBPeripheralDelegate {
169167
}
170168

171169
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
172-
self.context.invalidatedServicesSubject.send(invalidatedServices.map { Service($0) })
170+
Task {
171+
await self.context.invalidatedServicesSubject.send(invalidatedServices.map { Service($0) })
172+
}
173173
}
174174
}

Sources/Peripheral/Service.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
// Copyright (c) 2021 Manuel Fernandez-Peix Perez. All rights reserved.
22

33
import Foundation
4-
import CoreBluetooth
4+
@preconcurrency import CoreBluetooth
55

66
/// A collection of data and associated behaviors that accomplish a function or feature of a device.
77
/// - This class acts as a wrapper around `CBService`.
8-
public struct Service {
8+
public struct Service: Sendable {
99
let cbService: CBService
1010

1111
/// The Bluetooth-specific UUID of the service.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Copyright (c) 2024 Manuel Fernandez. All rights reserved.
2+
3+
import Foundation
4+
import CoreBluetooth
5+
6+
extension CBUUID: @unchecked Sendable {}

0 commit comments

Comments
 (0)