From 4d64dfab56b56a553365c38b800a3afa828c54e9 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sun, 10 Nov 2024 13:18:48 +0100 Subject: [PATCH] Improve how we persist the last manually disconnected device (#51) # Improve how we persist the last manually disconnected device ## :recycle: Current situation & Problem The feature that prevents the last known device from getting reconnected currently doesn't work properly. This PR fixes that to ensure that the last manually disconnected device won't be automatically connected again. With fully enabling Swift 6, we discovered that SwiftUI assumes that mutations are executed on the MainActor. Therefore, this PR needed to restructures how mutations are notified using Observation and all mutations are now shadowed on the MainActor for improved compatibility with SwiftUI. ## :pencil: Code of Conduct & Contributing Guidelines By submitting creating this pull request, you agree to follow our [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md): - [x] I agree to follow the [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md). --------- Co-authored-by: Paul Schmiedmayer --- .github/workflows/build-and-test.yml | 23 +---- Package.swift | 25 +---- Sources/SpeziBluetooth/Bluetooth.swift | 19 ++-- .../CoreBluetooth/BluetoothManager.swift | 10 +- .../Model/BluetoothManagerStorage.swift | 35 +++---- .../Model/DiscoverySession.swift | 16 +--- .../Model/GATTCharacteristic.swift | 29 +++++- .../Model/MainActorBuffered.swift | 93 +++++++++++++++++++ .../ManagedAtomicMainActorBuffered.swift | 83 +++++++++++++++++ .../Model/PeripheralStorage.swift | 61 +++++------- .../Model/Properties/Characteristic.swift | 18 ++-- .../Model/Properties/Service.swift | 6 +- .../CharacteristicAccessor.swift | 2 +- .../CharacteristicPeripheralInjection.swift | 6 +- .../DeviceStateTestInjections.swift | 12 +-- .../ConnectedDevicesEnvironmentModifier.swift | 12 ++- .../Utils/ConnectedDevices.swift | 6 +- .../UITests/UITests.xcodeproj/project.pbxproj | 40 +++----- .../xcshareddata/xcschemes/TestApp.xcscheme | 2 +- 19 files changed, 306 insertions(+), 192 deletions(-) create mode 100644 Sources/SpeziBluetooth/CoreBluetooth/Model/MainActorBuffered.swift create mode 100644 Sources/SpeziBluetooth/CoreBluetooth/Model/ManagedAtomicMainActorBuffered.swift diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index bef1dd2f..24104f93 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -24,16 +24,6 @@ jobs: scheme: SpeziBluetooth-Package artifactname: SpeziBluetooth-Package.xcresult resultBundle: SpeziBluetooth-Package.xcresult - packageios_latest: - name: Build and Test Swift Package iOS Latest - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - runsonlabels: '["macOS", "self-hosted", "spezi"]' - scheme: SpeziBluetooth-Package - xcodeversion: latest - swiftVersion: 6 - artifactname: SpeziBluetooth-Package-Latest.xcresult - resultBundle: SpeziBluetooth-Package-Latest.xcresult ios: name: Build and Test iOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -43,17 +33,6 @@ jobs: scheme: TestApp artifactname: TestApp-iOS.xcresult resultBundle: TestApp-iOS.xcresult - ios_latest: - name: Build and Test iOS Latest - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - runsonlabels: '["macOS", "self-hosted", "spezi"]' - path: 'Tests/UITests' - scheme: TestApp - xcodeversion: latest - swiftVersion: 6 - artifactname: TestApp-iOS-Latest.xcresult - resultBundle: TestApp-iOS-Latest.xcresult macos: name: Build and Test macOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -61,8 +40,8 @@ jobs: contents: read with: runsonlabels: '["macOS", "self-hosted", "bluetooth"]' - setupsigning: true path: 'Tests/UITests' + setupsigning: true artifactname: TestApp-macOS.xcresult resultBundle: TestApp-macOS.xcresult customcommand: "set -o pipefail && xcodebuild test -scheme 'TestApp' -configuration 'Test' -destination 'platform=macOS,arch=arm64,variant=Mac Catalyst' -derivedDataPath '.derivedData' -resultBundlePath 'TestApp-macOS.xcresult' -skipPackagePluginValidation -skipMacroValidation | xcbeautify" diff --git a/Package.swift b/Package.swift index ed3f8595..cdd31ac8 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.0 // // This source file is part of the Stanford Spezi open source project @@ -12,13 +12,6 @@ import class Foundation.ProcessInfo import PackageDescription -#if swift(<6) -let swiftConcurrency: SwiftSetting = .enableExperimentalFeature("StrictConcurrency") -#else -let swiftConcurrency: SwiftSetting = .enableUpcomingFeature("StrictConcurrency") -#endif - - let package = Package( name: "SpeziBluetooth", defaultLocalization: "en", @@ -54,10 +47,6 @@ let package = Package( resources: [ .process("Resources") ], - swiftSettings: [ - swiftConcurrency, - .enableUpcomingFeature("InferSendableFromCaptures") - ], plugins: [] + swiftLintPlugin() ), .target( @@ -67,9 +56,6 @@ let package = Package( .product(name: "ByteCoding", package: "SpeziNetworking"), .product(name: "SpeziNumerics", package: "SpeziNetworking") ], - swiftSettings: [ - swiftConcurrency - ], plugins: [] + swiftLintPlugin() ), .executableTarget( @@ -79,9 +65,6 @@ let package = Package( .target(name: "SpeziBluetoothServices"), .product(name: "ByteCoding", package: "SpeziNetworking") ], - swiftSettings: [ - swiftConcurrency - ], plugins: [] + swiftLintPlugin() ), .testTarget( @@ -90,9 +73,6 @@ let package = Package( .target(name: "SpeziBluetooth"), .target(name: "SpeziBluetoothServices") ], - swiftSettings: [ - swiftConcurrency - ], plugins: [] + swiftLintPlugin() ), .testTarget( @@ -104,9 +84,6 @@ let package = Package( .product(name: "NIO", package: "swift-nio"), .product(name: "XCTestExtensions", package: "XCTestExtensions") ], - swiftSettings: [ - swiftConcurrency - ], plugins: [] + swiftLintPlugin() ) ] diff --git a/Sources/SpeziBluetooth/Bluetooth.swift b/Sources/SpeziBluetooth/Bluetooth.swift index d965ac56..c53f388b 100644 --- a/Sources/SpeziBluetooth/Bluetooth.swift +++ b/Sources/SpeziBluetooth/Bluetooth.swift @@ -234,12 +234,14 @@ import Spezi public final class Bluetooth: Module, EnvironmentAccessible, Sendable { @Observable class Storage { - var nearbyDevices: OrderedDictionary = [:] + @MainActor var nearbyDevices: OrderedDictionary = [:] + + nonisolated init() {} } nonisolated static let logger = Logger(subsystem: "edu.stanford.spezi.bluetooth", category: "Bluetooth") - @SpeziBluetooth private let bluetoothManager = BluetoothManager() + private let bluetoothManager = BluetoothManager() /// The Bluetooth device configuration. /// @@ -247,9 +249,9 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { public nonisolated let configuration: Set // sadly Swifts "lazy var" won't work here with strict concurrency as it doesn't isolate the underlying lazy storage - @SpeziBluetooth private var _lazy_discoveryConfiguration: Set? + private var _lazy_discoveryConfiguration: Set? // swiftlint:disable:previous discouraged_optional_collection identifier_name - @SpeziBluetooth private var discoveryConfiguration: Set { + private var discoveryConfiguration: Set { guard let discoveryConfiguration = _lazy_discoveryConfiguration else { let discovery = configuration.parseDiscoveryDescription() self._lazy_discoveryConfiguration = discovery @@ -304,8 +306,10 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { /// Stores the connected device instance for every configured ``BluetoothDevice`` type. @Model @MainActor private var connectedDevicesModel = ConnectedDevicesModel() + + // we need to manually declare the synthesized property wrapper to avoid https://github.com/swiftlang/swift/issues/76005#issuecomment-2466703851 /// Injects the ``BluetoothDevice`` instances from the `ConnectedDevices` model into the SwiftUI environment. - @Modifier @MainActor private var devicesInjector: ConnectedDevicesEnvironmentModifier + @MainActor private var _devicesInjector: Modifier /// Configure the Bluetooth Module. @@ -322,13 +326,12 @@ public final class Bluetooth: Module, EnvironmentAccessible, Sendable { /// - Parameter devices: The set of configured devices. @MainActor public init( - @DiscoveryDescriptorBuilder _ devices: @Sendable () -> Set + @DiscoveryDescriptorBuilder _ devices: () -> Set ) { let configuration = devices() - let deviceTypes = configuration.deviceTypes self.configuration = configuration - self.devicesInjector = ConnectedDevicesEnvironmentModifier(configuredDeviceTypes: deviceTypes) + self._devicesInjector = Modifier(wrappedValue: ConnectedDevicesEnvironmentModifier(from: configuration)) Task { @SpeziBluetooth in self.observeDiscoveredDevices() diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index a3358d99..431e0d4e 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -109,6 +109,9 @@ public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint /// Currently ongoing discovery session. private var discoverySession: DiscoverySession? + /// The identifier of the last manually disconnected device. + /// This is to avoid automatically reconnecting to a device that was manually disconnected. + private(set) var lastManuallyDisconnectedDevice: UUID? /// The list of nearby bluetooth devices. /// @@ -197,6 +200,7 @@ public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint func cleanupCBCentral() { _centralManager = nil isScanningObserver = nil + lastManuallyDisconnectedDevice = nil logger.debug("Destroyed the underlying CBCentralManager.") } @@ -407,8 +411,6 @@ public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint storage.discoveredPeripherals.removeValue(forKey: id) - discoverySession?.clearManuallyDisconnectedDevice(for: id) - checkForCentralDeinit() } @@ -443,8 +445,6 @@ public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint func handlePeripheralDeinit(id uuid: UUID) { storage.retrievedPeripherals.removeValue(forKey: uuid) - discoverySession?.clearManuallyDisconnectedDevice(for: uuid) - checkForCentralDeinit() } @@ -488,7 +488,7 @@ public class BluetoothManager: Observable, Sendable, Identifiable { // swiftlint // stale timer is handled in the delegate method centralManager.cancelPeripheralConnection(peripheral.cbPeripheral) - discoverySession?.deviceManuallyDisconnected(id: peripheral.id) + lastManuallyDisconnectedDevice = peripheral.id } private func handledConnected(device: BluetoothPeripheral) async { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift index a677990b..e44fc7b1 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift @@ -13,10 +13,10 @@ import OrderedCollections @Observable final class BluetoothManagerStorage: ValueObservable, Sendable { - private let _isScanning = ManagedAtomic(false) - private let _state = ManagedAtomic(.unknown) + private let _isScanning = ManagedAtomicMainActorBuffered(false) + private let _state = ManagedAtomicMainActorBuffered(.unknown) - @ObservationIgnored private nonisolated(unsafe) var _discoveredPeripherals: OrderedDictionary = [:] + private let _discoveredPeripherals: MainActorBuffered> = .init([:]) private let rwLock = RWLock() @SpeziBluetooth var retrievedPeripherals: OrderedDictionary> = [:] { @@ -46,9 +46,7 @@ final class BluetoothManagerStorage: ValueObservable, Sendable { @inlinable var readOnlyDiscoveredPeripherals: OrderedDictionary { access(keyPath: \._discoveredPeripherals) - return rwLock.withReadLock { - _discoveredPeripherals - } + return _discoveredPeripherals.load(using: rwLock) } @SpeziBluetooth var state: BluetoothState { @@ -56,10 +54,13 @@ final class BluetoothManagerStorage: ValueObservable, Sendable { readOnlyState } set { - withMutation(keyPath: \._state) { - _state.store(newValue, ordering: .relaxed) + let didChange = _state.storeAndCompare(newValue) { @Sendable mutation in + self.withMutation(keyPath: \._state, mutation) + } + + if didChange { + _$simpleRegistrar.triggerDidChange(for: \.state, on: self) } - _$simpleRegistrar.triggerDidChange(for: \.state, on: self) for continuation in subscribedContinuations.values { continuation.yield(state) @@ -72,10 +73,13 @@ final class BluetoothManagerStorage: ValueObservable, Sendable { readOnlyIsScanning } set { - withMutation(keyPath: \._isScanning) { - _isScanning.store(newValue, ordering: .relaxed) + let didChange = _isScanning.storeAndCompare(newValue) { @Sendable mutation in + self.withMutation(keyPath: \._isScanning, mutation) + } + + if didChange { + _$simpleRegistrar.triggerDidChange(for: \.isScanning, on: self) // didSet } - _$simpleRegistrar.triggerDidChange(for: \.isScanning, on: self) // didSet } } @@ -84,11 +88,10 @@ final class BluetoothManagerStorage: ValueObservable, Sendable { readOnlyDiscoveredPeripherals } set { - withMutation(keyPath: \._discoveredPeripherals) { - rwLock.withWriteLock { - _discoveredPeripherals = newValue - } + _discoveredPeripherals.store(newValue, using: rwLock) { @Sendable mutation in + self.withMutation(keyPath: \._discoveredPeripherals, mutation) } + _$simpleRegistrar.triggerDidChange(for: \.discoveredPeripherals, on: self) // didSet } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift index eed2bc9d..326f51f4 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/DiscoverySession.swift @@ -100,10 +100,6 @@ class DiscoverySession: Sendable { private var configuration: BluetoothManagerDiscoveryState - /// The identifier of the last manually disconnected device. - /// This is to avoid automatically reconnecting to a device that was manually disconnected. - private(set) var lastManuallyDisconnectedDevice: UUID? - private var autoConnectItem: BluetoothWorkItem? private(set) var staleTimer: DiscoveryStaleTimer? @@ -149,16 +145,6 @@ class DiscoverySession: Sendable { rssi.intValue >= minimumRSSI && rssi.intValue != 127 } - func deviceManuallyDisconnected(id uuid: UUID) { - lastManuallyDisconnectedDevice = uuid - } - - func clearManuallyDisconnectedDevice(for uuid: UUID) { - if lastManuallyDisconnectedDevice == uuid { - lastManuallyDisconnectedDevice = nil - } - } - func deviceDiscoveryPostAction(device: BluetoothPeripheral, newlyDiscovered: Bool) { if newlyDiscovered { if staleTimer == nil { @@ -204,7 +190,7 @@ extension DiscoverySession { return nil // auto-connect is disabled } - guard lastManuallyDisconnectedDevice == nil && !manager.sbHasConnectedDevices else { + guard manager.lastManuallyDisconnectedDevice == nil && !manager.sbHasConnectedDevices else { return nil } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift index 2d570901..c4391b41 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/GATTCharacteristic.swift @@ -10,6 +10,17 @@ import CoreBluetooth import Foundation +struct CharacteristicAccessorCapture: Sendable { + let isNotifying: Bool + let properties: CBCharacteristicProperties + + fileprivate init(isNotifying: Bool, properties: CBCharacteristicProperties) { + self.isNotifying = isNotifying + self.properties = properties + } +} + + struct GATTCharacteristicCapture: Sendable { let isNotifying: Bool let value: Data? @@ -69,9 +80,10 @@ public final class GATTCharacteristic { private let captureLock = RWLock() - var captured: GATTCharacteristicCapture { - captureLock.withReadLock { - GATTCharacteristicCapture(from: self) + var captured: CharacteristicAccessorCapture { + access(keyPath: \.captured) + return captureLock.withReadLock { + CharacteristicAccessorCapture(isNotifying: _isNotifying, properties: properties) } } @@ -86,7 +98,10 @@ public final class GATTCharacteristic { @SpeziBluetooth func synchronizeModel(capture: GATTCharacteristicCapture) { + var shouldNotifyCapture = false + if capture.isNotifying != isNotifying { + shouldNotifyCapture = true withMutation(keyPath: \.isNotifying) { captureLock.withWriteLock { _isNotifying = capture.isNotifying @@ -107,6 +122,14 @@ public final class GATTCharacteristic { } } } + + if shouldNotifyCapture { + // self is never mutated or even accessed in the withMutation call + nonisolated(unsafe) let this = self + Task { @Sendable @MainActor in + this.withMutation(keyPath: \.captured) {} + } + } } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/MainActorBuffered.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/MainActorBuffered.swift new file mode 100644 index 00000000..80cc57c4 --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/MainActorBuffered.swift @@ -0,0 +1,93 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SpeziFoundation + + +final class MainActorBuffered: Sendable { + private nonisolated(unsafe) var unsafeValue: Value + @MainActor private(set) var mainActorValue: Value? + + init(_ value: Value) { + self.unsafeValue = value + self.mainActorValue = value + } + + func loadUnsafe() -> Value { + loadIfMainActor() ?? unsafeValue + } + + func load(using lock: NSLock) -> Value { + loadIfMainActor() ?? lock.withLock { + unsafeValue + } + } + + func load(using lock: RWLock) -> Value { + loadIfMainActor() ?? lock.withReadLock { + unsafeValue + } + } + + private func loadIfMainActor() -> Value? { + if Thread.isMainThread { + MainActor.assumeIsolated { + mainActorValue + } + } else { + nil + } + } + + private func _store(_ newValue: Value, mutation: sending @MainActor @escaping (@MainActor () -> Void) -> Void) { + if Thread.isMainThread { + MainActor.assumeIsolated { + let valueMutation = { @MainActor in + self.mainActorValue = newValue + } + mutation(valueMutation) + } + } else { + let valueMutation = { @MainActor in + self.mainActorValue = newValue + } + Task { @MainActor in + mutation(valueMutation) + } + } + } + + func store(_ newValue: Value, using lock: NSLock, mutation: sending @MainActor @escaping (@MainActor () -> Void) -> Void) { + lock.withLock { + unsafeValue = newValue + } + _store(newValue, mutation: mutation) + } + + func store(_ newValue: Value, using lock: RWLock, mutation: sending @MainActor @escaping (@MainActor () -> Void) -> Void) { + lock.withWriteLock { + unsafeValue = newValue + } + _store(newValue, mutation: mutation) + } +} + + +extension MainActorBuffered where Value: Equatable { + func storeAndCompare(_ newValue: Value, using lock: RWLock, mutation: sending @MainActor @escaping (@MainActor () -> Void) -> Void) -> Bool { + let didChange = lock.withWriteLock { + let didChange = unsafeValue != newValue + unsafeValue = newValue + return didChange + } + _store(newValue, mutation: mutation) + + return didChange + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/ManagedAtomicMainActorBuffered.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/ManagedAtomicMainActorBuffered.swift new file mode 100644 index 00000000..2bbea0db --- /dev/null +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/ManagedAtomicMainActorBuffered.swift @@ -0,0 +1,83 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Atomics +import Foundation + + +final class ManagedAtomicMainActorBuffered: Sendable where Value.AtomicRepresentation.Value == Value { + private let managedValue: ManagedAtomic + @MainActor private var mainActorValue: Value? + + init(_ value: Value) { + self.managedValue = ManagedAtomic(value) + self.mainActorValue = value + } + + @_semantics("atomics.requires_constant_orderings") + @inlinable + func load(ordering: AtomicLoadOrdering = .relaxed) -> Value { + if Thread.isMainThread { + MainActor.assumeIsolated { + mainActorValue + } ?? managedValue.load(ordering: ordering) + } else { + managedValue.load(ordering: ordering) + } + } + + @_semantics("atomics.requires_constant_orderings") + private func mutateMainActorBuffer( + _ newValue: Value, + mutation: sending @MainActor @escaping (@MainActor () -> Void) -> Void + ) { + if Thread.isMainThread { + MainActor.assumeIsolated { + let valueMutation = { @MainActor in + self.mainActorValue = newValue + } + mutation(valueMutation) + } + } else { + Task { @MainActor in + let valueMutation = { @MainActor in + self.mainActorValue = newValue + } + + mutation(valueMutation) + } + } + } + + @_semantics("atomics.requires_constant_orderings") + @inlinable + func store( + _ newValue: Value, + ordering: AtomicStoreOrdering = .relaxed, + mutation: sending @MainActor @escaping (@MainActor () -> Void) -> Void + ) { + managedValue.store(newValue, ordering: ordering) + mutateMainActorBuffer(newValue, mutation: mutation) + } +} + + +extension ManagedAtomicMainActorBuffered where Value: Equatable { + @_semantics("atomics.requires_constant_orderings") + @inlinable + func storeAndCompare( + _ newValue: Value, + ordering: AtomicUpdateOrdering = .relaxed, + mutation: sending @MainActor @escaping (@MainActor () -> Void) -> Void + ) -> Bool { + let previousValue = managedValue.exchange(newValue, ordering: ordering) + mutateMainActorBuffer(newValue, mutation: mutation) + + return previousValue != newValue + } +} diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift index d96d8192..95b28da4 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift @@ -16,14 +16,14 @@ import Foundation /// into a separate state container that is `@Observable`. @Observable final class PeripheralStorage: ValueObservable, Sendable { - private let _state: ManagedAtomic - private let _rssi: ManagedAtomic - private let _nearby: ManagedAtomic + private let _state: ManagedAtomicMainActorBuffered + private let _rssi: ManagedAtomicMainActorBuffered + private let _nearby: ManagedAtomicMainActorBuffered private let _lastActivityTimeIntervalSince1970BitPattern: ManagedAtomic // workaround to store store Date atomically // swiftlint:disable:previous identifier_name - @ObservationIgnored private nonisolated(unsafe) var _peripheralName: String? - @ObservationIgnored private nonisolated(unsafe) var _advertisementData: AdvertisementData + private let _peripheralName: MainActorBuffered + private let _advertisementData: MainActorBuffered // Its fine to have a single lock. Readers will be isolated anyways to the SpeziBluetooth global actor. // The only side-effect is, that readers will wait for any write to complete, which is fine as peripheralName is rarely updated. private let lock = RWLock() @@ -45,8 +45,9 @@ final class PeripheralStorage: ValueObservable, Sendable { @inlinable var name: String? { access(keyPath: \._peripheralName) access(keyPath: \._advertisementData) + return lock.withReadLock { - _peripheralName ?? _advertisementData.localName + _peripheralName.loadUnsafe() ?? _advertisementData.loadUnsafe().localName } } @@ -67,9 +68,7 @@ final class PeripheralStorage: ValueObservable, Sendable { @inlinable var readOnlyAdvertisementData: AdvertisementData { access(keyPath: \._advertisementData) - return lock.withReadLock { - _advertisementData - } + return _advertisementData.load(using: lock) } var readOnlyLastActivity: Date { @@ -80,16 +79,11 @@ final class PeripheralStorage: ValueObservable, Sendable { @SpeziBluetooth var peripheralName: String? { get { access(keyPath: \._peripheralName) - return lock.withReadLock { - _peripheralName - } + return _peripheralName.load(using: lock) } set { - let didChange = newValue != _peripheralName - withMutation(keyPath: \._peripheralName) { - lock.withWriteLock { - _peripheralName = newValue - } + let didChange = _peripheralName.storeAndCompare(newValue, using: lock) { @Sendable mutation in + self.withMutation(keyPath: \._peripheralName, mutation) } if didChange { @@ -103,9 +97,8 @@ final class PeripheralStorage: ValueObservable, Sendable { readOnlyRssi } set { - let didChange = newValue != readOnlyRssi - withMutation(keyPath: \._rssi) { - _rssi.store(newValue, ordering: .relaxed) + let didChange = _rssi.storeAndCompare(newValue) { @Sendable mutation in + self.withMutation(keyPath: \._rssi, mutation) } if didChange { _$simpleRegistrar.triggerDidChange(for: \.rssi, on: self) @@ -118,11 +111,8 @@ final class PeripheralStorage: ValueObservable, Sendable { readOnlyAdvertisementData } set { - let didChange = newValue != _advertisementData - withMutation(keyPath: \._advertisementData) { - lock.withWriteLock { - _advertisementData = newValue - } + let didChange = _advertisementData.storeAndCompare(newValue, using: lock) { @Sendable mutation in + self.withMutation(keyPath: \._advertisementData, mutation) } if didChange { @@ -136,10 +126,10 @@ final class PeripheralStorage: ValueObservable, Sendable { readOnlyState } set { - let didChange = newValue != readOnlyState - withMutation(keyPath: \._state) { - _state.store(newValue, ordering: .relaxed) + let didChange = _state.storeAndCompare(newValue) { @Sendable mutation in + self.withMutation(keyPath: \._state, mutation) } + if didChange { _$simpleRegistrar.triggerDidChange(for: \.state, on: self) } @@ -151,9 +141,8 @@ final class PeripheralStorage: ValueObservable, Sendable { readOnlyNearby } set { - let didChange = newValue != readOnlyNearby - withMutation(keyPath: \._nearby) { - _nearby.store(newValue, ordering: .relaxed) + let didChange = _nearby.storeAndCompare(newValue) { @Sendable mutation in + self.withMutation(keyPath: \._nearby, mutation) } if didChange { @@ -166,11 +155,11 @@ final class PeripheralStorage: ValueObservable, Sendable { @ObservationIgnored let _$simpleRegistrar = ValueObservationRegistrar() init(peripheralName: String?, rssi: Int, advertisementData: AdvertisementData, state: PeripheralState, lastActivity: Date = .now) { - self._peripheralName = peripheralName - self._advertisementData = advertisementData - self._rssi = ManagedAtomic(rssi) - self._state = ManagedAtomic(state) - self._nearby = ManagedAtomic(false) + self._peripheralName = MainActorBuffered(peripheralName) + self._advertisementData = MainActorBuffered(advertisementData) + self._rssi = ManagedAtomicMainActorBuffered(rssi) + self._state = ManagedAtomicMainActorBuffered(state) + self._nearby = ManagedAtomicMainActorBuffered(false) self._lastActivity = lastActivity self._lastActivityTimeIntervalSince1970BitPattern = ManagedAtomic(lastActivity.timeIntervalSince1970.bitPattern) } diff --git a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift index f1de5a05..a58704fd 100644 --- a/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift +++ b/Sources/SpeziBluetooth/Model/Properties/Characteristic.swift @@ -217,7 +217,7 @@ public struct Characteristic: Sendable { struct CharacteristicCaptureRetrieval: Sendable { // workaround to make the retrieval of the `capture` property Sendable private nonisolated(unsafe) let characteristic: GATTCharacteristic - var capture: GATTCharacteristicCapture { + var capture: CharacteristicAccessorCapture { characteristic.captured } @@ -226,7 +226,7 @@ public struct Characteristic: Sendable { } } - @ObservationIgnored private nonisolated(unsafe) var _value: Value? + private let _value: MainActorBuffered @ObservationIgnored private nonisolated(unsafe) var _capture: CharacteristicCaptureRetrieval? // protects both properties above private let lock = RWLock() @@ -241,12 +241,10 @@ public struct Characteristic: Sendable { @inlinable var readOnlyValue: Value? { access(keyPath: \._value) - return lock.withReadLock { - _value - } + return _value.load(using: lock) } - var capture: GATTCharacteristicCapture? { + var capture: CharacteristicAccessorCapture? { let characteristic = lock.withReadLock { _capture } @@ -263,15 +261,13 @@ public struct Characteristic: Sendable { } init(initialValue: Value?) { - self._value = initialValue + self._value = MainActorBuffered(initialValue) } @inlinable func inject(_ value: Value?) { - withMutation(keyPath: \._value) { - lock.withWriteLock { - _value = value - } + _value.store(value, using: lock) { @Sendable mutation in + self.withMutation(keyPath: \._value, mutation) } } } diff --git a/Sources/SpeziBluetooth/Model/Properties/Service.swift b/Sources/SpeziBluetooth/Model/Properties/Service.swift index 67397a47..e73b716c 100644 --- a/Sources/SpeziBluetooth/Model/Properties/Service.swift +++ b/Sources/SpeziBluetooth/Model/Properties/Service.swift @@ -66,7 +66,7 @@ public struct Service { } } - private let _serviceState = ManagedAtomic(.notPresent) + private let _serviceState = ManagedAtomicMainActorBuffered(.notPresent) var serviceState: ServiceState { get { @@ -74,8 +74,8 @@ public struct Service { return _serviceState.load(ordering: .relaxed) } set { - withMutation(keyPath: \.serviceState) { - _serviceState.store(newValue, ordering: .relaxed) + _serviceState.store(newValue) { @Sendable mutation in + self.withMutation(keyPath: \.serviceState, mutation) } } } diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift index a0fca730..6e77e56c 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift @@ -46,7 +46,7 @@ import CoreBluetooth /// - ``sendRequest(_:timeout:)`` public struct CharacteristicAccessor { private let storage: Characteristic.Storage - private let capturedCharacteristic: GATTCharacteristicCapture? + private let capturedCharacteristic: CharacteristicAccessorCapture? init( diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift index 1eeac52a..98d61d4a 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicPeripheralInjection.swift @@ -322,9 +322,9 @@ extension CharacteristicPeripheralInjection where Value: ControlPointCharacteris throw error } - - async let _ = withTimeout(of: timeout) { @SpeziBluetooth in - transaction.signalTimeout() + + async let _ = withTimeout(of: timeout) { + await transaction.signalTimeout() } return try await response diff --git a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateTestInjections.swift b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateTestInjections.swift index 447e89ae..905c6704 100644 --- a/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateTestInjections.swift +++ b/Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateTestInjections.swift @@ -12,7 +12,7 @@ import Foundation @Observable final class DeviceStateTestInjections: Sendable { @ObservationIgnored private nonisolated(unsafe) var _subscriptions: ChangeSubscriptions? - @ObservationIgnored private nonisolated(unsafe) var _injectedValue: Value? + private let _injectedValue: MainActorBuffered = .init(nil) private let lock = NSLock() // protects both properties above var subscriptions: ChangeSubscriptions? { @@ -31,15 +31,11 @@ final class DeviceStateTestInjections: Sendable { var injectedValue: Value? { get { access(keyPath: \.injectedValue) - return lock.withLock { - _injectedValue - } + return _injectedValue.load(using: lock) } set { - withMutation(keyPath: \.injectedValue) { - lock.withLock { - _injectedValue = newValue - } + _injectedValue.store(newValue, using: lock) { @Sendable mutation in + self.withMutation(keyPath: \.injectedValue, mutation) } } } diff --git a/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift b/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift index f4405ac8..4811e5c6 100644 --- a/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift +++ b/Sources/SpeziBluetooth/Modifier/ConnectedDevicesEnvironmentModifier.swift @@ -13,6 +13,8 @@ private struct ConnectedDeviceEnvironmentModifier: View @Environment(ConnectedDevicesModel.self) var connectedDevices + @State private var devicesList = ConnectedDevices() + init() {} @@ -23,11 +25,9 @@ private struct ConnectedDeviceEnvironmentModifier: View device as? Device } - let devicesList = ConnectedDevices(connectedDevicesList) - content .environment(firstConnectedDevice) - .environment(devicesList) + .environment(ConnectedDevices(connectedDevicesList)) } } @@ -39,10 +39,14 @@ struct ConnectedDevicesEnvironmentModifier: ViewModifier { var connectedDevices - init(configuredDeviceTypes: [any BluetoothDevice.Type]) { + nonisolated init(configuredDeviceTypes: [any BluetoothDevice.Type]) { self.configuredDeviceTypes = configuredDeviceTypes } + nonisolated init(from configuration: Set) { + self.init(configuredDeviceTypes: configuration.deviceTypes) + } + func body(content: Content) -> some View { let modifiers = configuredDeviceTypes.map { $0.deviceEnvironmentModifier } diff --git a/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift b/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift index 578e82cf..e27ae74d 100644 --- a/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift +++ b/Sources/SpeziBluetooth/Utils/ConnectedDevices.swift @@ -30,10 +30,10 @@ import SwiftUI /// } /// } /// ``` -@Observable -public final class ConnectedDevices { - private let devices: [Device] +public final class ConnectedDevices: Observable { + let devices: [Device] + @MainActor init(_ devices: [Device] = []) { self.devices = devices } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 62bb1a49..0cf5d7dc 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -220,7 +220,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1540; + LastUpgradeCheck = 1610; TargetAttributes = { 2F6D139128F5F384007C25D6 = { CreatedOnToolsVersion = 14.1; @@ -373,11 +373,11 @@ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; - OTHER_SWIFT_FLAGS = "-enable-upcoming-feature InferSendableFromCaptures"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Debug; @@ -432,11 +432,11 @@ MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; - OTHER_SWIFT_FLAGS = "-enable-upcoming-feature InferSendableFromCaptures"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; XROS_DEPLOYMENT_TARGET = 1.0; }; @@ -445,13 +445,12 @@ 2F6D13B728F5F386007C25D6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 637867499T; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 484YT3X9X7; + DEVELOPMENT_TEAM = 637867499T; ENABLE_HARDENED_RUNTIME = NO; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; @@ -469,7 +468,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapplication2; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.bluetooth.testapplication; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = YES; @@ -477,7 +476,6 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Debug; @@ -485,15 +483,13 @@ 2F6D13B828F5F386007C25D6 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 637867499T; + DEVELOPMENT_TEAM = 637867499T; ENABLE_HARDENED_RUNTIME = NO; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; @@ -520,7 +516,6 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Release; @@ -528,7 +523,6 @@ 2F6D13BD28F5F386007C25D6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 637867499T; @@ -543,7 +537,6 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = TestApp; }; @@ -552,13 +545,10 @@ 2F6D13BE28F5F386007C25D6 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 637867499T; + DEVELOPMENT_TEAM = 637867499T; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; @@ -571,7 +561,6 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = TestApp; }; @@ -634,11 +623,11 @@ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; - OTHER_SWIFT_FLAGS = "-enable-upcoming-feature InferSendableFromCaptures"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = TEST; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Test; @@ -646,15 +635,13 @@ 2FB07588299DDB6000C0B37F /* Test */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 637867499T; + DEVELOPMENT_TEAM = 637867499T; ENABLE_HARDENED_RUNTIME = NO; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; @@ -681,7 +668,6 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Test; @@ -689,13 +675,10 @@ 2FB07589299DDB6000C0B37F /* Test */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 637867499T; + DEVELOPMENT_TEAM = 637867499T; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; @@ -708,7 +691,6 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = TestApp; }; diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index 1ee434a9..a1d77362 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -1,6 +1,6 @@