From 7530b3f96517a7e3ef5555aed5f99ae01d1ea35c Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Tue, 22 Jul 2025 11:52:16 -0600 Subject: [PATCH 01/27] Updated build to Swift 6.1 and demo to iOS18/macOS15 --- .../ReliaBLE Demo.xcodeproj/project.pbxproj | 18 +++++++----------- .../xcschemes/ReliaBLE Demo.xcscheme | 2 +- Package.resolved | 11 ++++++++++- Package.swift | 4 ++-- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj b/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj index d41845c..3975f63 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj @@ -180,7 +180,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1610; - LastUpgradeCheck = 1620; + LastUpgradeCheck = 1640; TargetAttributes = { 8B771A932CEC3B9A002F0E31 = { CreatedOnToolsVersion = 16.1; @@ -316,6 +316,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = AU92CYH9BP; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -378,6 +379,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = AU92CYH9BP; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -406,7 +408,6 @@ CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"ReliaBLE Demo/Preview Content\""; - DEVELOPMENT_TEAM = AU92CYH9BP; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -424,10 +425,10 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.five3apps.ReliaBLE-Demo"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -451,7 +452,6 @@ CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"ReliaBLE Demo/Preview Content\""; - DEVELOPMENT_TEAM = AU92CYH9BP; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -469,10 +469,10 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.five3apps.ReliaBLE-Demo"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -493,7 +493,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = AU92CYH9BP; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.1; MACOSX_DEPLOYMENT_TARGET = 15.1; @@ -517,7 +516,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = AU92CYH9BP; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.1; MACOSX_DEPLOYMENT_TARGET = 15.1; @@ -540,7 +538,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = AU92CYH9BP; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.1; MACOSX_DEPLOYMENT_TARGET = 15.1; @@ -563,7 +560,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = AU92CYH9BP; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.1; MACOSX_DEPLOYMENT_TARGET = 15.1; diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/xcshareddata/xcschemes/ReliaBLE Demo.xcscheme b/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/xcshareddata/xcschemes/ReliaBLE Demo.xcscheme index 05c24f5..cf4c131 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/xcshareddata/xcschemes/ReliaBLE Demo.xcscheme +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/xcshareddata/xcschemes/ReliaBLE Demo.xcscheme @@ -1,6 +1,6 @@ Date: Tue, 22 Jul 2025 12:03:34 -0600 Subject: [PATCH 02/27] Migrated peripheral UI to Observable --- .../ReliaBLE Demo/Peripheral/PeripheralManager.swift | 6 +++--- .../ReliaBLE Demo/Peripheral/PeripheralView.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralManager.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralManager.swift index bf901a7..7b11d4b 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralManager.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralManager.swift @@ -27,9 +27,9 @@ import CoreBluetooth import SwiftUI -class PeripheralManager: NSObject, ObservableObject, CBPeripheralManagerDelegate { - @Published var state: CBManagerState = .unknown - @Published var isAdvertising: Bool = false +@Observable class PeripheralManager: NSObject, CBPeripheralManagerDelegate { + var state: CBManagerState = .unknown + var isAdvertising: Bool = false private var peripheralManager: CBPeripheralManager! private var peripheralName: String = "" private var serviceUUID: CBUUID? diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralView.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralView.swift index fa174ec..f330325 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralView.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralView.swift @@ -30,7 +30,7 @@ private let defaultPeripheralName = "ReliaBLE Demo" private let defaultServiceUUID = "12345678-90AB-CDEF-1234-567890ABCDEF" struct PeripheralView: View { - @StateObject private var peripheralManager = PeripheralManager() + @State private var peripheralManager = PeripheralManager() @State private var peripheralName: String = defaultPeripheralName @State private var serviceUUIDString: String = defaultServiceUUID From a91d0ec63fa2c3859efb6d435c2a8c8d20d89516 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Tue, 22 Jul 2025 12:06:36 -0600 Subject: [PATCH 03/27] Refactored central UI to Observable --- Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift | 2 +- .../ReliaBLE Demo/Central/CentralViewModel.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift index bb11ef8..bcb936e 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift @@ -38,7 +38,7 @@ struct CentralView: View { @Query private var discoveries: [DiscoveryEvent] @Query private var devices: [Device] - @StateObject private var viewModel = CentralViewModel() + @State private var viewModel = CentralViewModel() @State private var selectedView: String = "Devices" var body: some View { diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift index d006025..0a27c96 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift @@ -31,9 +31,9 @@ import SwiftUI import ReliaBLE -class CentralViewModel: ObservableObject { - @Published var currentState: BluetoothState = .unknown - @Published var servicesInput = "" +@Observable class CentralViewModel { + var currentState: BluetoothState = .unknown + var servicesInput = "" var cancellables = Set() From 75b145f51282666a2a7a98b98e6c8587d40bff21 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Tue, 22 Jul 2025 11:54:11 -0600 Subject: [PATCH 04/27] Fixed incorrect concurrent queues --- Sources/ReliaBLE/BluetoothManager.swift | 2 +- Sources/ReliaBLE/PeripheralManager.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ReliaBLE/BluetoothManager.swift b/Sources/ReliaBLE/BluetoothManager.swift index 426b7c4..fdc7204 100644 --- a/Sources/ReliaBLE/BluetoothManager.swift +++ b/Sources/ReliaBLE/BluetoothManager.swift @@ -35,7 +35,7 @@ class BluetoothManager: NSObject, CBCentralManagerDelegate { private let peripheralManager: PeripheralManager private var centralManager: CBCentralManager? - private let queue = DispatchQueue(label: "com.five3apps.relia-ble.bluetoothmanager", qos: .userInitiated, attributes: [.concurrent]) + private let queue = DispatchQueue(label: "com.five3apps.relia-ble.bluetoothmanager", qos: .userInitiated) // MARK: - Initialization diff --git a/Sources/ReliaBLE/PeripheralManager.swift b/Sources/ReliaBLE/PeripheralManager.swift index b440967..0d7ce70 100644 --- a/Sources/ReliaBLE/PeripheralManager.swift +++ b/Sources/ReliaBLE/PeripheralManager.swift @@ -32,7 +32,7 @@ class PeripheralManager { private var discoveredPeripherals = [Peripheral]() private let discoveredPeripheralsSubject = PassthroughSubject<[Peripheral], Never>() - private let queue = DispatchQueue(label: "com.five3apps.relia-ble.peripheralmanager", qos: .userInitiated, attributes: [.concurrent]) + private let queue = DispatchQueue(label: "com.five3apps.relia-ble.peripheralmanager", qos: .userInitiated) public var discoveredPeripheralsPublisher: AnyPublisher<[Peripheral], Never> { discoveredPeripheralsSubject.eraseToAnyPublisher() From dec7edc8b8c7d3f195e4857054c0608f6afdb57e Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Mon, 1 Jun 2026 18:45:03 -0600 Subject: [PATCH 05/27] Pulled in updated Willow and updated for Swift 6 --- .../ReliaBLE Demo.xcodeproj/project.pbxproj | 6 ++++++ Package.resolved | 12 ++++++------ Package.swift | 2 +- .../xcshareddata/swiftpm/Package.resolved | 12 ++++++------ Sources/ReliaBLE/Logging/LogMessage.swift | 10 +++++----- Sources/ReliaBLE/Logging/LogWriters.swift | 4 ++-- Sources/ReliaBLE/Logging/LoggingService.swift | 2 +- Sources/ReliaBLE/ReliaBLEConfig.swift | 2 +- Sources/ReliaBLE/ReliaBLEManager.swift | 2 +- 9 files changed, 29 insertions(+), 23 deletions(-) diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj b/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj index 3975f63..1d5a954 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj @@ -339,7 +339,10 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; }; name = Debug; }; @@ -394,7 +397,10 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_STRICT_CONCURRENCY = complete; }; name = Release; }; diff --git a/Package.resolved b/Package.resolved index bd5c4ae..fa12bf0 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "c98ef1aaa5cf95e58af520a10dd1c8f3bf4fe46d0bbb1b51886ec513fd07429d", + "originHash" : "379c094ef4c3a923a4a2c21c4e25ed1058c6b3865059734470458f26ec9827d3", "pins" : [ { "identity" : "ios-corebluetooth-mock", "kind" : "remoteSourceControl", "location" : "https://github.com/NordicSemiconductor/IOS-CoreBluetooth-Mock.git", "state" : { - "revision" : "d1e0321027728da6edf5b974cf113524bad2034a", - "version" : "1.0.1" + "revision" : "5748c9e8b1750e0d7bc09243c099ff618f211cdf", + "version" : "1.0.6" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-docc-plugin", "state" : { - "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", - "version" : "1.4.3" + "revision" : "647c708be89f834fa6a6d4945442793a77ddf5b6", + "version" : "1.5.0" } }, { @@ -34,7 +34,7 @@ "location" : "https://github.com/itsniper/Willow", "state" : { "branch" : "main", - "revision" : "8952bb792c9206caa3b69fcf7432d2b5280c3db5" + "revision" : "beeaf007a6566ced5f3f2c9842d760f97c9300f6" } } ], diff --git a/Package.swift b/Package.swift index f73f3cf..406e30e 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), .package(url: "https://github.com/itsniper/Willow", branch: "main"), - .package(url: "https://github.com/NordicSemiconductor/IOS-CoreBluetooth-Mock.git", .upToNextMinor(from: "1.0.1")), + .package(url: "https://github.com/NordicSemiconductor/IOS-CoreBluetooth-Mock.git", .upToNextMinor(from: "1.0.6")), ], targets: [ .target( diff --git a/ReliaBLE.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ReliaBLE.xcworkspace/xcshareddata/swiftpm/Package.resolved index 763ae51..fa12bf0 100644 --- a/ReliaBLE.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ReliaBLE.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "53a66b41b9d6fb3dd74aa6489373804c3995b488c51536a7681d6a00779be14a", + "originHash" : "379c094ef4c3a923a4a2c21c4e25ed1058c6b3865059734470458f26ec9827d3", "pins" : [ { "identity" : "ios-corebluetooth-mock", "kind" : "remoteSourceControl", "location" : "https://github.com/NordicSemiconductor/IOS-CoreBluetooth-Mock.git", "state" : { - "revision" : "d1e0321027728da6edf5b974cf113524bad2034a", - "version" : "1.0.1" + "revision" : "5748c9e8b1750e0d7bc09243c099ff618f211cdf", + "version" : "1.0.6" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-docc-plugin", "state" : { - "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", - "version" : "1.4.3" + "revision" : "647c708be89f834fa6a6d4945442793a77ddf5b6", + "version" : "1.5.0" } }, { @@ -34,7 +34,7 @@ "location" : "https://github.com/itsniper/Willow", "state" : { "branch" : "main", - "revision" : "8952bb792c9206caa3b69fcf7432d2b5280c3db5" + "revision" : "beeaf007a6566ced5f3f2c9842d760f97c9300f6" } } ], diff --git a/Sources/ReliaBLE/Logging/LogMessage.swift b/Sources/ReliaBLE/Logging/LogMessage.swift index 429c2b9..c1acfdb 100644 --- a/Sources/ReliaBLE/Logging/LogMessage.swift +++ b/Sources/ReliaBLE/Logging/LogMessage.swift @@ -24,11 +24,11 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -@preconcurrency import Willow +import Willow /// An enumeration representing defined tags that can be associated with log messages. These are used /// to categorize log messages for better organization and filtering. -public enum LogTag { +public enum LogTag: Sendable { /// A tag representing a specific category the log message relates to. See ``Category`` for more details. case category(Category) /// A tag representing a specific peripheral device, identified by its unique identifier. @@ -36,7 +36,7 @@ public enum LogTag { /// A special tag representing a specific category of log messages, such as "scanning" or "connection". /// Log messages can have multiple category tags but that should be the exception rather than the rule. - public enum Category: String { + public enum Category: String, Sendable { case scanning case connection } @@ -70,12 +70,12 @@ public struct LogMessage: Willow.LogMessage { /// comma-separated list of all category tags and other keys contain their respective tag /// values. /// - Returns: A dictionary containing tag attributes. - public var attributes: [String : Any] { + public var attributes: [String: any Sendable] { guard let tags else { return [:] } - var result: [String: Any] = [:] + var result: [String: any Sendable] = [:] var categories: [String] = [] for tag in tags { diff --git a/Sources/ReliaBLE/Logging/LogWriters.swift b/Sources/ReliaBLE/Logging/LogWriters.swift index b0b8ab2..368de70 100644 --- a/Sources/ReliaBLE/Logging/LogWriters.swift +++ b/Sources/ReliaBLE/Logging/LogWriters.swift @@ -27,7 +27,7 @@ import Foundation import os -@preconcurrency import Willow +import Willow /// The LogModifier protocol defines a single method for modifying a log message after it has been constructed. /// This is very flexible allowing any object that conforms to modify messages in any way it wants. @@ -45,7 +45,7 @@ public typealias ConsoleWriter = Willow.ConsoleWriter /// The OSLogWriter class runs all modifiers in the order they were created and passes the resulting message /// off to an OSLog with the specified subsystem and category. -public class OSLogWriter: LogModifierWriter { +public final class OSLogWriter: LogModifierWriter { public let subsystem: String public let category: String diff --git a/Sources/ReliaBLE/Logging/LoggingService.swift b/Sources/ReliaBLE/Logging/LoggingService.swift index d91a365..c211828 100644 --- a/Sources/ReliaBLE/Logging/LoggingService.swift +++ b/Sources/ReliaBLE/Logging/LoggingService.swift @@ -24,7 +24,7 @@ import Foundation -@preconcurrency import Willow +import Willow /// Service for managing all logging within ReliaBLE. public class LoggingService { diff --git a/Sources/ReliaBLE/ReliaBLEConfig.swift b/Sources/ReliaBLE/ReliaBLEConfig.swift index 20fd13b..569c11b 100644 --- a/Sources/ReliaBLE/ReliaBLEConfig.swift +++ b/Sources/ReliaBLE/ReliaBLEConfig.swift @@ -24,7 +24,7 @@ import Foundation -@preconcurrency import Willow +import Willow /// The `LogLevel` struct defines all the default log levels for ReliaBLE. Each default log level has a defined bitmask /// that is used to satisfy the raw value backing the log level. diff --git a/Sources/ReliaBLE/ReliaBLEManager.swift b/Sources/ReliaBLE/ReliaBLEManager.swift index 68deffc..ed68dee 100644 --- a/Sources/ReliaBLE/ReliaBLEManager.swift +++ b/Sources/ReliaBLE/ReliaBLEManager.swift @@ -28,7 +28,7 @@ import Combine import Foundation import CoreBluetooth -@preconcurrency import Willow +import Willow /// The main entry point for the ReliaBLE library. public class ReliaBLEManager { From 7d84735c25c428558c6edd66a9273936584f264b Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Tue, 22 Jul 2025 14:12:59 -0600 Subject: [PATCH 06/27] Updated demo to Swift 6 with Complete concurrency checking --- Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj b/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj index 1d5a954..d4b6eb9 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj @@ -442,7 +442,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; XROS_DEPLOYMENT_TARGET = 2.1; }; @@ -486,7 +486,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; XROS_DEPLOYMENT_TARGET = 2.1; }; From f7a96a2142cb000459faffd4067e8f5a74dfaea3 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Wed, 3 Jun 2026 16:17:25 -0600 Subject: [PATCH 07/27] Added Swift 6 concurrency investigation report --- .../swift6-concurrency-audit-2026-05-13.md | 1001 +++++++++++++++++ 1 file changed, 1001 insertions(+) create mode 100644 docs/investigations/swift6-concurrency-audit-2026-05-13.md diff --git a/docs/investigations/swift6-concurrency-audit-2026-05-13.md b/docs/investigations/swift6-concurrency-audit-2026-05-13.md new file mode 100644 index 0000000..8249d44 --- /dev/null +++ b/docs/investigations/swift6-concurrency-audit-2026-05-13.md @@ -0,0 +1,1001 @@ +# Investigation: Swift 6 Concurrency Audit of `Sources/ReliaBLE` + +> **Update — 2026-06-01 (post-audit).** While this audit was being written, the +> repo merged PR #16 (commit `3657f45`) bringing in **Willow 7.0**, which is +> fully Swift 6 / strict-concurrency clean upstream. All `@preconcurrency +> import Willow` directives have been removed; `LogMessage.attributes` is now +> `[String: any Sendable]`; `LogTag`/`LogTag.Category` are explicit `Sendable`; +> `OSLogWriter` is `final`. **This obsoletes Cluster 4 ("Logging stack pinned +> to non-`Sendable` Willow") and the "drop Willow → `os.Logger`" recommendation +> in §C below.** See the *Post-Willow-7.0 Revision* note at the end of each +> affected section. Clusters 1–3 (BLE state races, Combine surface, `@unchecked +> Sendable` peripheral classes) are unchanged and still drive the bulk of the +> recommended refactor. + +## Summary +Audit of the ReliaBLE library's three core files (`BluetoothManager.swift`, +`ReliaBLEManager.swift`, `PeripheralManager.swift`) against modern Swift 6.1 +strict-concurrency / iOS 18 best practices. The library is greenfield, so the +audit is unconstrained by API backward compatibility. + +Design constraints the recommendations must respect: +- Callable from `@MainActor` (SwiftUI) without `await` ceremony being painful, + but **not required** to live on the main actor. +- Callable entirely from background actors / nonisolated contexts. +- Three-target SPM trick must keep working (`CBCentralManagerFactory` swap). +- `forceMock: true` is load-bearing — must not be "cleaned up". +- DocC catalog & public API surface need to be maintained. + +## Symptoms / Issues Suspected (a priori) +- `BluetoothManager` is a plain `NSObject` class with mutable state (`centralManager`, + Combine subjects). State reads/writes are not all routed through its private + `DispatchQueue`. Delegate callbacks come in on the queue; public methods are + callable from any thread. +- `PeripheralManager` is `@unchecked Sendable` with `queue.sync`-based + serialization. Mutating callbacks happen on the **BluetoothManager queue**, not + the `PeripheralManager` queue, so concurrency is sound by "single producer" + rather than by isolation. +- `Peripheral` is `@unchecked Sendable` using a manual `NSLock`. It exposes + `CBPeripheral?` and `[String: Any]?` — neither is `Sendable`. +- `ReliaBLEManager` is a non-`final`, non-`Sendable` `class`. With Swift 6 + strict concurrency, sharing it across actors will warn/error. +- Combine `PassthroughSubject` / `CurrentValueSubject` are not `Sendable` in + Swift 6. The public API surfaces `AnyPublisher<...>` which has the same + issue. Modern equivalent: `AsyncStream` / `Observation`. +- `@preconcurrency import Willow` papers over Willow's non-`Sendable` Logger. +- `ReliaBLEConfig` has stored mutable properties (struct is fine) but + `[LogWriter]` and `DispatchQueue` `Sendable` posture should be checked. +- `authorize()` is a synchronous throwing method — may race with delegate + callbacks if state changes mid-call. +- `BluetoothManager.startScanning/stopScanning` read `centralManager.isScanning` + off-queue (CB API contract is queue-affined). + +## Background / Prior Research + +External research (Swift 6.1 / iOS 18, CoreBluetooth + concurrency) — summary from +Phase 1.5 explore agent. + +**Apple guidance / state of the ecosystem (mid-2024 → 2025):** +- WWDC24 "Migrate your app to Swift 6" (session 10169) endorses **actors** as + the primary tool for serializing state behind callback-style APIs. +- Apple has **not** shipped `AsyncSequence` / `Observation`-flavored + CoreBluetooth APIs through iOS 18. `CBCentralManagerDelegate` and the + delegate-queue model are still the only mechanism. `CBCentralManager` and + `CBPeripheral` are not `Sendable`. +- `CBCentralManager.authorization` remains a synchronous class property; no + async authorization API exists. + +**Current best-practice patterns for a Swift 6 BLE library:** +1. **Global actor + delegate shim** — declare a `@globalActor BluetoothActor`, + put all BLE state on it, and use a small `NSObject` "shim" that adopts + `CBCentralManagerDelegate`. Each delegate callback is `nonisolated` and + immediately `Task { @BluetoothActor in … }`'s into the actor. This avoids + `@unchecked Sendable`, eliminates manual queue/lock dances, and gives the + compiler real isolation guarantees. +2. **Custom serial executor** (Swift 5.9+) — alternatively, an `actor` can + adopt a `SerialExecutor` backed by the CoreBluetooth `DispatchQueue`, so + the actor *is* the delegate queue. Eliminates the hop in delegate + callbacks. Slightly more advanced but ideal for hot paths like discovery. +3. **AsyncSequence / AsyncStream for events** — replace + `PassthroughSubject` / `CurrentValueSubject` (non-Sendable in Swift 6). + `AsyncStream` is itself `Sendable`; its continuation can be yielded from + an actor. `Observation` (`@Observable`) is the right tool for "current + snapshot" state for SwiftUI; less appropriate for high-volume event + streams like advertisement bursts (use AsyncStream there). +4. **Snapshot/value peripherals** — keep mutable peripheral state inside an + actor; hand consumers a `Sendable` `struct Peripheral` snapshot. Avoids + leaking `CBPeripheral` or `[String: Any]` across actor boundaries. For + long-lived "handles" (e.g., to connect/read later), expose an opaque + `Sendable` ID (e.g., a wrapped `UUID`). + +**Anti-patterns in 2026:** +- `@unchecked Sendable` + manual `NSLock` for ad-hoc data classes — defeats + Swift 6's whole point and is brittle when the API surface grows. +- Publishing `AnyPublisher<…, Never>` as the supported public event API in a + Swift 6 library. Combine subjects/publishers are not `Sendable`; consumers + on different actors get warnings and `@preconcurrency` hacks. (Combine + itself is on a slow deprecation path for new code.) +- `@preconcurrency import` of a transitive dependency *and* re-exposing + types from it — fine for internal use, problematic if it leaks into the + public surface. + +**Survey of comparable open-source libraries** (per explore agent): Most +pre-Swift-6 BLE libraries (Bluejay, RxBluetoothKit, BlueCapKit) have not yet +been ported. Newer entrants and forks (e.g. `swift-async-bluetooth`-style) +trend toward `actor`-based managers and `AsyncStream` event APIs. No widely +adopted Swift 6 BLE library has settled into a clear "winning" public API +shape — there is room for ReliaBLE to set a sensible one. + +**Citations:** WWDC24 session 10169 (Migrate to Swift 6); Apple iOS 18 +release notes (no CoreBluetooth async additions); Swift Forums discussions +on CoreBluetooth + actors (queue-sync race patterns). + +## Investigator Findings + +### 1. `BluetoothManager.swift` — Isolation Domain & Mutable State Audit + +#### Mutable State Inventory + +| Property | Line | Type | Read From | Written From | Synchronization | +|---|---|---|---|---|---| +| `centralManager` | 41 | `CBCentralManager?` | `startScanning`, `stopScanning`, `updateState`, `authorize`, `setupCentralManager` | `setupCentralManager` | **None** | +| `stateSubject` | 81 | `CurrentValueSubject` | `currentState` (line 90) | `updateState` | **None** (Combine internal) | +| `discoverySubject` | 199 | `PassthroughSubject` | (sink subscribers) | `centralManager(_:didDiscover:...)` | **None** (Combine internal) | +| `peripheralManager` | 39 | `PeripheralManager` | delegate callbacks | init only (immutable ref) | OK (immutable `let`) | +| `log` | 38 | `LoggingService` | all methods | init only | OK (immutable `let`) | +| `queue` | 42 | `DispatchQueue` | — | — | Only passed to CBCentralManager; **not used to protect own state** | + +#### Methods: Isolation-Domain Analysis + +- **`init(loggingService:peripheralManager:)`** :55 — Runs on **caller's thread** (unknown domain). Calls `setupCentralManager()` and `updateState()` synchronously. The `guard centralManager == nil` check on :69 has a **TOCTOU race**: two caller threads could both pass the nil check and create duplicate managers. + +- **`setupCentralManager()`** :68 — Runs on **caller's thread** (called from `init` and `authorize()`). Reads/writes `centralManager` without queue protection. The factory call `CBCentralManagerFactory.instance(delegate: self, queue: queue, ...)` on :74 creates a real `CBCentralManager` with the `queue` dispatch queue. After this point, delegate callbacks will arrive on `queue`. + +- **`state`** :85 — Computed property, returns `AnyPublisher`. Callable from any thread. The `AnyPublisher` itself is **non-Sendable** under Swift 6. Subscribers on different actors will produce concurrency warnings. + +- **`currentState`** :90 — Reads `stateSubject.value` synchronously on caller's thread. `CurrentValueSubject.value` is documented as thread-safe for reads but can race with concurrent `send(...)` in Swift 6 strict mode since the subject is not `Sendable`. + +- **`updateState()`** :93 — Runs on **three different threads**: + 1. Caller's thread (from `init`:105) + 2. Caller's thread (from `startScanning`:172, `stopScanning`:197) + 3. **`queue` dispatch queue** (from `centralManagerDidUpdateState`:230) + + Reads `centralManager?.isScanning` (:108) and `centralManager?.state` (:113) — these are CoreBluetooth properties that Apple documents must be accessed from the **delegate queue**. When `updateState()` is called from `startScanning`/`stopScanning` (caller's thread), this **violates the CoreBluetooth API contract**. Sends on `stateSubject` from all three threads → `CurrentValueSubject` receive on multiple threads without isolation. + +- **`authorize()`** :137 — Runs on **caller's thread**. Reads `CBCentralManager.authorization` (class property — OK from any thread) and calls `setupCentralManager()`. If `authorize()` is called from a SwiftUI button (`@MainActor`) while a delegate callback fires on `queue`, both touch `centralManager` and `stateSubject` concurrently. **Race condition**. + +- **`startScanning(services:)`** :155 — Runs on **caller's thread**. Guards on `centralManager` (:156) and `centralManager.state` (:162) **off-queue**. After `centralManager.scanForPeripherals(...)` on :168, reads `centralManager.isScanning` (:170) **off-queue** — violates CoreBluetooth contract that these should be read on the delegate queue. Calls `updateState()` → sends on `stateSubject` from the caller's thread. + +- **`stopScanning()`** :179 — Same pattern as `startScanning`. Reads `centralManager`, calls `centralManager.stopScan()`, reads `centralManager.isScanning` (:195) — all **off-queue**. + +#### Delegate Callbacks — The "Queue Bridge" + +- **`centralManagerDidUpdateState(_:)`** :206 — `CBCentralManagerDelegate` callback. Arrives on **`queue`** (dispatch queue, not an actor). Calls `peripheralManager.refreshPeripherals(using:)` or `peripheralManager.invalidatePeripherals()`, then `updateState()`. These are `nonisolated` calls to methods on `PeripheralManager`/`BluetoothManager` — they execute on the calling queue's thread. This is the **only domain** where CoreBluetooth contract is satisfied for `updateState()`. + +- **`centralManager(_:didDiscover:advertisementData:rssi:)`** :226 — Arrives on **`queue`**. Constructs a `PeripheralDiscoveryEvent` (:227), sends on `discoverySubject` (:237), then calls `peripheralManager.discoveredPeripheral(...)` (:239). The `PassthroughSubject.send(...)` on :237 dispatches to Combine subscribers — if subscribers are on `@MainActor` (as in the Demo via `.receive(on: DispatchQueue.main)`), the subscriber closure hops to main. The Combine pipeline itself is **non-Sendable** and would emit concurrency warnings in Swift 6 if the subscriber closure is actor-isolated. + +#### Under `-strict-concurrency=complete` — Compile-Time Predictions + +1. **`BluetoothManager` is not `Sendable` and not actor-isolated**: Any cross-actor reference to a `BluetoothManager` instance would generate a warning (e.g., captured in a `Task` or passed between actors). + +2. **`CurrentValueSubject` / `PassthroughSubject` are non-Sendable stored properties**: The compiler will flag these as requiring `@MainActor` isolation or `@unchecked Sendable` on the class. + +3. **`state` and `peripheralDiscoveries` return `AnyPublisher<..., Never>`**: External consumers subscribing from actor-isolated contexts will get warnings because `AnyPublisher` is non-Sendable. + +4. **`CBCentralManagerDelegate` conformance on a non-`@MainActor` `NSObject`**: This compiles fine (CoreBluetooth delegates are not `@MainActor`-constrained). The `centralManager?` property is weakly referenced but the delegate callbacks are dispatched on the queue — this works at the type level but not at the data-race level. + +5. **The `queue` dispatch queue**: `DispatchQueue` is `Sendable`, so storing it is fine, but it's not used for any internal synchronization. + +#### Summary: Root Cause + +`BluetoothManager` has **three concurrency domains** touching the same mutable state: +- **Domain 1**: Caller's thread (unknown — could be `@MainActor`, background `actor`, or `nonisolated`) — `init`, `authorize`, `startScanning`, `stopScanning`, and the `updateState()` calls they trigger. +- **Domain 2**: The private `queue` dispatch queue — all `CBCentralManagerDelegate` callbacks. +- **Domain 3**: Combine subscribers' threads (e.g., `DispatchQueue.main` via `.receive(on:)`) — downstream effects of `stateSubject.send(...)` and `discoverySubject.send(...)`. + +None of these domains are synchronized. The `queue` only governs CoreBluetooth callback delivery, not the class's own mutable state. + +--- + +### 2. `PeripheralManager.swift` — `queue.sync` Single-Producer Verification + +#### Call-Site Trace + +| Method | Called From | Calling Thread | Sync Mechanism | +|---|---|---|---| +| `discoveredPeripheral(_:advertisementData:rssi:)` :49 | `BluetoothManager.centralManager(_:didDiscover:...)` :239 | `BluetoothManager.queue` dispatch queue | `queue.sync` :52 | +| `invalidatePeripherals()` :74 | `BluetoothManager.centralManagerDidUpdateState` :219 | `BluetoothManager.queue` dispatch queue | `queue.sync` :75 | +| `refreshPeripherals(using:)` :81 | `BluetoothManager.centralManagerDidUpdateState` :213 | `BluetoothManager.queue` dispatch queue | `queue.sync` :82 | + +All three call sites originate from `BluetoothManager`'s `queue`, which is a **different** dispatch queue than `PeripheralManager`'s `queue`. So the pattern is: +``` +BluetoothManager.queue (dispatch) + → calls PeripheralManager.discoveredPeripheral(...) + → PeripheralManager.queue.sync { ... } +``` + +This means the calling thread (BluetoothManager's queue) **blocks** waiting for PeripheralManager's queue to execute the block. The `queue.sync` provides actual thread-safety regardless of who calls it — it's not merely "safe by coincidence." If any other thread called these methods, `queue.sync` would still serialize access. + +#### `discoveredPeripheralsSubject.send(...)` Under the Lock + +All three methods call `discoveredPeripheralsSubject.send(discoveredPeripherals)` **inside** the `queue.sync` block (:62, :69, :78, :90, :99). This is correct: the array snapshot is captured while holding the queue, so subscribers receive a consistent `[Peripheral]`. + +However, **`PassthroughSubject.send(...)` synchronously invokes downstream subscribers** on the calling thread (unless a `.receive(on:)` operator is in the pipeline). This means PeripheralManager's queue thread runs subscriber closures: +- If a subscriber does `.receive(on: DispatchQueue.main)`, the actual work hops to main → fine. +- If a subscriber does **not** specify a scheduler, it runs on PeripheralManager's queue thread. Heavy work in the subscriber (e.g., SwiftData operations, as the Demo does) would **block the PeripheralManager queue**, delaying subsequent discovery processing. +- Under Swift 6, if the subscriber closure is actor-isolated (e.g., `@MainActor`), the compiler will flag this because `PassthroughSubject` is non-Sendable and the closure crosses isolation boundaries. + +#### Risks with `@unchecked Sendable` + +`PeripheralManager` is `@unchecked Sendable` (:38). This tells the compiler "trust me, I'm thread-safe." However: +- `discoveredPeripheralsSubject` is a non-Sendable `PassthroughSubject` stored as a property. The compiler accepts this because of `@unchecked Sendable`, but Swift 6 strict mode may still warn about non-Sendable stored properties in Sendable types. +- `discoveredPeripherals` array is non-Sendable (contains `Peripheral` which is `@unchecked Sendable` — itself problematic, see §3). Protected by `queue.sync`, so runtime safe but type-level unsafe. + +--- + +### 3. `Peripheral.swift` — Locking Audit & Sendable Violations + +#### Lock Coverage Matrix + +| Property | Backing | Getter Line | Getter Locks? | Setter Line | Setter Locks? | Comment | +|---|---|---|---|---|---|---| +| `id` | `let id: String` | 56 | N/A (immutable) | init | N/A | Fine | +| `peripheral` | `_peripheral` | 73 | **Yes** | `update` :128, `invalidate` :142, init :118 | **Yes** | OK | +| `rssi` | `_rssi` | 91 | **Yes** | `update` :130, init :119 | **Yes** | OK | +| `advertisementData` | `_advertisementData` | 99 | **Yes** | `update` :129, init :120 | **Yes** | OK | +| `lastSeen` | `_lastSeen` | 108 | **Yes** | `update` :133, init :123 | **Yes** | OK | +| `peripheralIdentifier` | computed | 63 | **Indirect** (via `peripheral` getter) | — | — | Lock released before `.identifier` read | +| `name` | computed | 79 | **Indirect** (via `peripheral` + `advertisementData` getters) | — | — | Two separate lock acquisitions | +| `serviceUUIDs` | computed | 84 | **Indirect** (via `advertisementData` getter) | — | — | Lock released before dictionary subscript | + +#### `peripheralIdentifier` — Double-Lock / Race Analysis + +:63: `peripheral?.identifier` — calls `peripheral` getter (:73), which locks, returns `_peripheral`, unlocks. Then `.identifier` is read on the **now-unlocked** `CBPeripheral` reference. Is this a race? + +- `CBPeripheral.identifier` is a `UUID` property that is stable for the lifetime of the `CBPeripheral` object. +- The `CBPeripheral` reference is kept alive by ARC even if `invalidateCBPeripheral()` concurrently sets `_peripheral = nil` — the already-returned reference is independently retained. +- **Verdict**: No race on the identifier value itself. The comment "No lock needed here since this is a computed property that accesses `peripheral` which already handles its own synchronization" is **misleading** — it *does* acquire the lock (indirectly), then reads the identifier outside it. But the read is safe because `CBPeripheral.identifier` is monotonic/immutable. + +#### `name` — Two Separate Lock Acquisitions + +:79: `peripheral?.name ?? advertisementData?[CBAdvertisementDataLocalNameKey] as? String` + +This acquires the `mutationLock` **twice**: once in `peripheral` getter, once in `advertisementData` getter. Between the two acquisitions: +- `peripheral` could be invalidated (set to nil) by `invalidateCBPeripheral()` on another thread — but the returned reference is independently retained, so `?.name` is safe. +- `advertisementData` could be replaced by `update()` on another thread — but the returned dictionary is independently retained, so the subscript is safe. +- **Verdict**: Functionally safe but inefficient. The two separate lock acquires mean the combined read is not atomic — `peripheral?.name` and `advertisementData?[...]` could reflect different states of the object. + +#### Sendable Violations (Type-Level) + +`Peripheral` is `@unchecked Sendable` (:42). The following publicly exposed types violate Sendable: + +1. **`advertisementData: [String: Any]?`** (:99) — `[String: Any]` is fundamentally non-Sendable (`Any` can contain non-Sendable values). Exposing this in a `@unchecked Sendable` type means callers can extract non-Sendable data across actor boundaries under a false Sendable promise. + +2. **`peripheral: CBPeripheral?`** (:73) — `CBPeripheral` is not `Sendable`. The getter returns the raw CoreBluetooth object. If a consumer on `@MainActor` holds a reference while a `BluetoothManager.queue` callback invalidates it, the consumer has a dangling but ARC-retained `CBPeripheral` — not a crash, but semantically stale. + +3. **`serviceUUIDs: [CBUUID]?`** (:84) — `CBUUID` is a CoreBluetooth reference type, unlikely to be `Sendable`. The array of `CBUUID` objects extracted from the advertisement dictionary carries the same non-Sendable risk. + +#### `hash(into:)` and `==` + +- `hash(into:)` :147 is `nonisolated` and only accesses `id` (immutable `let String`). Safe. +- `==` :152 only compares `lhs.id == rhs.id`. Safe. + +--- + +### 4. `ReliaBLEManager.swift` — Cross-Actor Sharing Risk Analysis + +#### Type-Level Posture + +- **Non-`final` class** (:43) — can be subclassed. Subclass could add mutable state unprotected. +- **No `Sendable` conformance** — under `-strict-concurrency=complete`, passing a `ReliaBLEManager` instance between actors/domains produces a warning. +- **No actor isolation** — not `@MainActor`, not a `@globalActor`, not an `actor`. Executes on the caller's context. + +#### Effective Concurrency Contract of Each Public Member + +| Member | Line | Caller Thread | Thread-Safety Mechanism | Cross-Actor Risk | +|---|---|---|---|---| +| `init(config:)` | 56 | Caller's thread | None needed (creates child objects) | Creates `BluetoothManager` which can start `CBCentralManager` — see §1 | +| `loggingService` | 45 | Caller's thread | `LoggingService` is `Sendable` | `enabled` setter on Willow's non-Sendable `Logger` races if toggled from two actors | +| `state` | 63 | Caller's thread | Returns non-Sendable `AnyPublisher` | Subscribers on different actors get warnings | +| `currentState` | 68 | Caller's thread | Reads `CurrentValueSubject.value` — see §1 | May race with `send` from delegate queue | +| `authorizeBluetooth()` | 76 | Caller's thread | Delegates to `BluetoothManager.authorize()` — see §1 | Race with delegate callbacks | +| `peripheralDiscoveries` | 82 | Caller's thread | Returns non-Sendable `AnyPublisher` | Same as `state` | +| `discoveredPeripherals` | 87 | Caller's thread | Returns non-Sendable `AnyPublisher` | Same as above; `[Peripheral]` elements are `@unchecked Sendable` — see §3 | +| `startScanning(services:)` | 97 | Caller's thread | Delegates to `BluetoothManager.startScanning` — see §1 | All the §1 races | +| `stopScanning()` | 102 | Caller's thread | Delegates to `BluetoothManager.stopScanning` — see §1 | All the §1 races | + +#### Cross-Actor Sharing Scenario + +Consider the Demo's pattern: +- `ReliaBLE_DemoApp` (:65) creates a `ReliaBLEManager` on `@MainActor` (SwiftUI `App` is `@MainActor`). +- The instance is injected into `CentralViewModel` (an `@Observable` class, `@MainActor`-isolated by SwiftUI convention). +- All usage is from `@MainActor` — **currently no cross-actor sharing**. + +But if a hypothetical consumer: +1. Creates `ReliaBLEManager` on `@MainActor` (SwiftUI) +2. Passes it to a background `actor` for off-main work + +Then: +- **Compiler**: warns that non-Sendable `ReliaBLEManager` crosses actor boundary (unless `@preconcurrency import ReliaBLE`) +- **Runtime races**: + - Actor A calls `startScanning()` while Actor B calls `authorizeBluetooth()` — both touch `BluetoothManager.centralManager` unsynchronized :41 + - Actor A reads `currentState` while delegate callback on `queue` calls `updateState()` → `stateSubject.send(...)` — **data race** on CurrentValueSubject internal state + - Subscriber on Actor A's Combine pipeline receives from the delegate queue's `PassthroughSubject.send(...)` — **three-domain fan-out** (caller actor, delegate queue, subscriber actor) + +#### `loggingService` Public Exposure Risk + +:45: `public let loggingService: LoggingService` — while `LoggingService` is `Sendable`, it wraps Willow's non-Sendable `Logger`. The `enabled` property (:43 in LoggingService.swift) reads/writes `willowLogger.enabled` without synchronization. Two actors toggling `enabled` simultaneously on the same instance would race on Willow's internal state. + +--- + +### 5. Mock Target Parity — `CoreBluetoothMockAliases.swift` + +#### Typealias Coverage + +All CoreBluetooth types used by the library have corresponding `CBM*` mock typealiases (lines 41–69): +- `CBCentralManager` → `CBMCentralManager` (:48) +- `CBCentralManagerDelegate` → `CBMCentralManagerDelegate` (:49) +- `CBPeripheral` → `CBMPeripheral` (:50) +- `CBUUID` → `CBMUUID` (:43) +- `CBCentralManagerFactory` → `CBMCentralManagerFactory` (:42) +- All `CBManagerState`, `CBPeripheralState`, `CBError`, `CBATTError`, advertisement data keys, connection options, etc. + +#### Compatibility with Actor-Based Refactor + +If the library moves to an `actor`-based design: + +1. **`CBCentralManagerFactory.instance(...)`** — the mock factory (`CBMCentralManagerFactory`) must accept the same `(delegate:queue:options:forceMock:)` signature. The mock factory typealiased at :42 presumably does. **No issue expected**. + +2. **Delegate = Actor** — If the `BluetoothManager` becomes a `@globalActor` or `actor`, the delegate must be `nonisolated` to conform to `CBCentralManagerDelegate` (an `NSObjectProtocol`). The shim pattern (a small `NSObject` that forwards callbacks into the actor via `Task { @BluetoothActor in ... }`) works identically with both real and mock `CBCentralManager`. **No issue expected**. + +3. **`CBPeripheral` → `CBMPeripheral` typealias** — If `Peripheral` is replaced with a `Sendable` struct snapshot (as recommended by best practices), the internal reference to `CBPeripheral` goes away, and the typealias becomes irrelevant for that data path. The struct would hold a `UUID` identifier instead of a reference. **No issue expected**. + +4. **`AsyncStream` over Combine** — The mock target doesn't use Combine subjects directly; it swaps the delegate and factory. Switching to `AsyncStream` for event delivery removes the non-Sendable `AnyPublisher` problem from the public API. The mock's `simulatePeripheral(...)` / `simulateStateChange(...)` APIs would still drive the same delegate callbacks, which flow into `AsyncStream.Continuation.yield(...)`. **No issue expected**. + +5. **Subtlety: `CBMPeripheral.identifier`** — In CoreBluetoothMock, `CBMPeripheral.identifier` returns a mock-generated UUID. This is the same shape as the real `CBPeripheral.identifier`. If the refactor stores UUIDs instead of `CBPeripheral` references, mock and real paths remain consistent. **No issue expected**. + +6. **Subtlety: `retrievePeripherals(withIdentifiers:)`** — Called in `PeripheralManager.refreshPeripherals` :82. `CBMCentralManager` provides `retrievePeripherals(withIdentifiers:)` that returns mock `CBMPeripheral` instances. This works identically to the real path. **No issue expected**. + +#### Verdict + +The three-target SPM trick and `CoreBluetoothMock` integration are **fully compatible** with any of the three modernization patterns (global actor + shim, custom serial executor, or AsyncStream). No mock surface adjustments needed. + +--- + +### 6. Public API Shape Changes — Modernization Impact Catalog + +#### `ReliaBLEManager` — Members and Modernization Effect + +| Member | Current Signature | Modernized Signature | Breaking? | +|---|---|---|---| +| `init(config:)` | `public init(config: ReliaBLEConfig = ReliaBLEConfig())` | Same (class) or `public init(config:)` (actor) | **No** | +| `state` | `public var state: AnyPublisher` | `public var state: AsyncStream` or `AsyncPublisher` | **BREAKING** — Combine → concurrency | +| `currentState` | `public var currentState: BluetoothState` | `public var currentState: BluetoothState` (if actor, requires `await` to read) | **BREAKING** — synchronous → async | +| `authorizeBluetooth()` | `public func authorizeBluetooth() throws` | `public func authorizeBluetooth() async throws` (if actor) | **BREAKING** — synchronous → async | +| `peripheralDiscoveries` | `public var peripheralDiscoveries: AnyPublisher` | `public var peripheralDiscoveries: AsyncStream` | **BREAKING** — Combine → concurrency | +| `discoveredPeripherals` | `public var discoveredPeripherals: AnyPublisher<[Peripheral], Never>` | `public var discoveredPeripherals: AsyncStream<[Peripheral]>` | **BREAKING** — Combine → concurrency + `Peripheral` type change | +| `startScanning(services:)` | `public func startScanning(services: [CBUUID]?)` | Same (or `async` if actor) | **Potentially BREAKING** if `async` | +| `stopScanning()` | `public func stopScanning()` | Same (or `async` if actor) | **Potentially BREAKING** if `async` | +| `loggingService` | `public let loggingService: LoggingService` | Same | **No** (but `enabled` race risk — see §4) | + +#### Exposed Types — Sendable Audit for Modernization + +| Type | Location | Currently Sendable? | Must Change? | +|---|---|---|---| +| `BluetoothState` | `BluetoothManager.swift:249` | `Sendable` enum | **No change needed** | +| `AuthorizationStatus` | `BluetoothManager.swift:252` (typealias) | `CBManagerAuthorization` — platform Sendable status unknown | **May need explicit `@retroactive Sendable`** | +| `AuthorizationError` | `BluetoothManager.swift:300` | Implicitly `Sendable` (no reference-type associated values) | **No change needed** | +| `PeripheralDiscoveryEvent` | `PeripheralDiscoveryEvent.swift:32` | `struct` with `[String: Any]` → **NOT Sendable** | **Must change** — remove `[String: Any]` or make it a `[String: Sendable]`; expose extracted fields | +| `Peripheral` | `Peripheral.swift:42` | `@unchecked Sendable` class with non-Sendable members | **Must change** — replace with `Sendable` struct snapshot | +| `ReliaBLEConfig` | `ReliaBLEConfig.swift:33` | `struct` with `[LogWriter]` (non-Sendable protocol type) and `DispatchQueue` | **Must change** — needs `Sendable` conformance or `@preconcurrency` | +| `LoggingService` | `LoggingService.swift:35` | `Sendable` class wrapping non-Sendable Willow `Logger` | **Fragile** — relies on `@preconcurrency import Willow` | +| `LogTag` | `LogMessage.swift:22` | Enum with `String` associated values | **Needs explicit `Sendable`** conformance | +| `LogMessage` | `LogMessage.swift:60` | Struct with `[String: Any]` in `attributes` → **NOT Sendable** | **Must change** — `[String: Any]` is non-Sendable | +| `LogLevel` | `ReliaBLEConfig.swift:32` (typealias) | Non-Sendable Willow type | **Needs `@preconcurrency` at use sites** | +| `LogWriter`, `LogModifier`, `LogModifierWriter`, `ConsoleWriter`, `OSLogWriter` | Various typealiases | Non-Sendable Willow types | **Needs `@preconcurrency` at use sites** | +| `AnyPublisher` | Return type | **NOT Sendable** | **Must change** to `AsyncStream` / `AsyncSequence` | +| `AnyPublisher` | Return type | **NOT Sendable** | **Must change** | +| `AnyPublisher<[Peripheral], Never>` | Return type | **NOT Sendable** | **Must change** | + +#### Demo API Usage — What Actually Depends on the Public Surface + +From `CentralViewModel.swift` (:51–139) and `ReliaBLE_DemoApp.swift` (:41–71): + +| API Used | How Used | Modernization Impact | +|---|---|---| +| `ReliaBLEManager(config:)` init | SwiftUI `@main App` property | Minimal | +| `.state` publisher | `.receive(on: DispatchQueue.main).assign(to: \.currentState, on: self)` in an `@Observable` class | Combine pipeline → must adapt to `for await` or `AsyncStream` | +| `.peripheralDiscoveries` publisher | `.receive(on: DispatchQueue.main).sink { ... }` → SwiftData insert | Combine pipeline → must adapt | +| `.discoveredPeripherals` publisher | `.receive(on: DispatchQueue.main).sink { ... }` → SwiftData fetch/upsert | Combine pipeline → must adapt; access to `Peripheral.id`, `.name`, `.lastSeen` | +| `.authorizeBluetooth()` | `try?` synchronous call from `@MainActor` view model method | If becomes `async throws`, call site needs `Task { try? await ... }` | +| `.startScanning(services:)` | Direct call from `@MainActor` | Same as above | +| `.stopScanning()` | Direct call from `@MainActor` | Same as above | +| `Peripheral.id` | String comparison for device matching | Preserved if `Sendable` struct retains `id: String` | +| `Peripheral.name` | Display and model update | Preserved if `Sendable` struct retains `name: String?` | +| `Peripheral.lastSeen` | Model update | Preserved if `Sendable` struct retains `lastSeen: Date?` | + +The Demo depends on 3 Combine publishers, 3 synchronous methods, and 3 `Peripheral` properties. All would need adaptation if the library modernizes — but the library is **greenfield (pre-1.0)** with a single consuming app (Demo), so breaking changes are acceptable per the audit's charter. + +--- + +### 7. Additional Findings — Logging Layer & Config + +#### `LoggingService` :35 — `Sendable` But Internal Dependency is Not + +`LoggingService` is `final class` and `Sendable` but wraps `willowLogger: Logger` (:38) — Willow's `Logger` is **not Sendable** (confirmed via upstream source probe). The `@preconcurrency import Willow` at `ReliaBLEManager.swift:41` suppresses the compiler warning for this usage. Under `-strict-concurrency=complete` without `@preconcurrency`, this would **fail to compile**. + +Additionally, `enabled` (:43) is a mutable `Bool` setter that writes to `willowLogger.enabled` — concurrent writes from two actors to the same `LoggingService` instance would race on Willow's internal non-Sendable `Logger`. + +#### `ReliaBLEConfig` :33 — Non-Sendable Struct Passed Across Actor Boundaries + +`ReliaBLEConfig` is passed to `ReliaBLEManager.init(config:)`. If `ReliaBLEManager` becomes an actor or is created on a background actor from a `@MainActor` context, the config struct must cross actor boundaries. But: +- `logWriters: [LogWriter]` — `LogWriter` is a Willow protocol, non-Sendable. +- `logQueue: DispatchQueue` — `DispatchQueue` is `Sendable`, fine. +- `logLevels: LogLevel` — Willow type, non-Sendable. + +The struct would need `Sendable` conformance (or `@preconcurrency` suppression) to cross actor boundaries. + +#### `OSLogWriter` :53 (LogWriters.swift) — Class Type Not Sendable + +`OSLogWriter` stores an `os.OSLog` (:65) which is a C-backed object — not Sendable-annotated in the OSLog framework. For `ReliaBLEConfig` to be `Sendable`, `OSLogWriter` would need to be `@unchecked Sendable` or the `logWriters` array would need `@preconcurrency` treatment. + +#### `LogMessage` :60 — `[String: Any]` in `attributes` Property + +The `attributes` computed property returns `[String: Any]` — non-Sendable. This is passed to Willow's `Logger` methods as part of the `LogMessage` protocol conformance. Since Willow's `Logger` doesn't require `Sendable` log messages, this works currently, but the type itself can't be `Sendable`. + +#### `PeripheralDiscoveryEvent` :32 — Non-Sendable `[String: Any]` Stored Property + +`PeripheralDiscoveryEvent` is a `public struct` that stores `advertisementData: [String: Any]` (:49). Under `-strict-concurrency=complete`: +- The struct cannot be `Sendable` because it stores a non-Sendable value. +- It is sent through `PassthroughSubject` — the subject is non-Sendable, and the element type being non-Sendable compounds the issue. +- For `AsyncStream`, the element type would need to be `Sendable` — requiring `advertisementData` to be removed or replaced with a `Sendable` representation (e.g., `[String: String]` for known keys, or wrapped in a `@unchecked Sendable` box). + +#### `testFunction()` :106 (ReliaBLEManager.swift) — Dead Code in Public Surface + +`func testFunction() -> String` is `internal` (no `public` modifier) so it's not part of the public API — but it's dead code in the production target. Harmless for concurrency but should be cleaned up. + +## Investigation Log + +**Phase 1 — Triage.** Read all three target files plus surrounding context +(`Peripheral.swift`, `PeripheralDiscoveryEvent.swift`, `LoggingService.swift`, +`ReliaBLEConfig.swift`, `CBCentralManagerFactory.swift`, `Package.swift`, +`CoreBluetoothMockAliases.swift`). Captured initial symptoms in this report. + +**Phase 1.5 — External research.** Dispatched an explore agent to survey +WWDC24/25 Swift 6 + CoreBluetooth guidance, modern AsyncSequence / Observation +patterns, and comparable open-source BLE libraries. Findings recorded in +`## Background / Prior Research` above. + +**Phase 2 — Context Builder.** Seeded the file selection with all relevant +production sources, mock-target shim, logging stack, and codemaps for Demo + +mock parallels. Initial oracle pass identified the same root-cause cluster +captured in this report's `## Symptoms` section. + +**Phase 3 — Pair Investigator.** Detailed file:line-anchored audit appended +under `## Investigator Findings` (§§1–7). Covered: mutable state inventory in +`BluetoothManager`, three-domain race analysis, `queue.sync` cross-queue +serialization verification in `PeripheralManager`, lock-coverage matrix for +`Peripheral`, cross-actor sharing scenarios for `ReliaBLEManager`, mock-target +compatibility checks, public API impact catalogue, and logging-layer Sendable +audit. + +**Phase 4 — Oracle Synthesis.** Two rounds. Round 1 produced an +architecture/migration sketch. Round 2 sharpened three design decisions: +(a) `ReliaBLEManager` should be nonisolated `Sendable`, not `@MainActor`, to +honor the "main actor not required" constraint; (b) multi-subscriber +`AsyncStream` events implemented via an in-actor broadcaster, not external +dependencies; (c) stale-`Peripheral`-handle operations should throw an +explicit error rather than auto-rescan or return optional. + +**Phase 5 — Post-audit revision (Willow 7.0 integration).** Maintainer +flagged that commit `3657f45` in this repo had already pulled in Willow 7.0 +(Swift 6 / strict-concurrency clean upstream) and removed every +`@preconcurrency import Willow`. Verified by fetching the upstream Willow +source at the pinned revision (`beeaf007a6` on `itsniper/Willow main`): +`Logger: @unchecked Sendable` with documented invariant; all public +protocols (`LogMessage`, `LogWriter`, `LogModifierWriter`, `LogModifier`, +`LogFilter`, `LogLevel`, `LogSource`) refine `Sendable`; closure parameters +on `Logger`'s logging APIs carry `@Sendable`. ReliaBLE's local source +changes in `7476f48`: `@preconcurrency` removed from 5 files, +`LogMessage.attributes: [String: Any]` → `[String: any Sendable]`, +`LogTag`/`LogTag.Category` marked `Sendable`, `OSLogWriter` declared +`final`. Revised: Cluster 4 in Root Cause, Recommendation §C, and the +migration order — Step 1 "Drop Willow" is removed; remaining logging work +is small polish (explicit `Sendable` on `ReliaBLEConfig`, verification +that `OSLogWriter` synthesizes `Sendable` under strict mode, DocC note on +the inherited `enabled` race). Clusters 1–3 and the rest of the +architecture recommendations are unchanged. + +--- + +## Root Cause / Findings + +The library has **three Swift 6 concurrency issue clusters**, not one. They +must be fixed together because they are mutually reinforcing — fixing one +without the others would leave the rest as `@unchecked Sendable` papering or +`@preconcurrency` import escape hatches. + +### Cluster 1 — Unsynchronized BLE state (`BluetoothManager`) +`centralManager`, `stateSubject`, and `discoverySubject` are touched from +**three concurrency domains** with no synchronization: +- Caller's thread (`init`, `authorize`, `startScanning`, `stopScanning`, and + their `updateState()` calls) — `BluetoothManager.swift:65, 137, 155, 179, 93`. +- The private `queue` `DispatchQueue` — all `CBCentralManagerDelegate` + callbacks — `BluetoothManager.swift:206, 226`. +- Combine subscribers' threads — anywhere a downstream `.sink` runs. + +`updateState()` reads `centralManager?.state` and `centralManager?.isScanning` +from the caller thread (`BluetoothManager.swift:108, 113, 170, 195`), which +**violates CoreBluetooth's documented queue-affinity contract** (these +properties must be read on the delegate queue). The private `queue` exists +only to receive delegate callbacks; it is never used to protect +`BluetoothManager`'s own mutable state. + +### Cluster 2 — Non-`Sendable` event surface (Combine in the public API) +Every public event surface returns `AnyPublisher<…, Never>` which is **not +`Sendable` in Swift 6** (`ReliaBLEManager.swift:63, 82, 87`). The backing +`PassthroughSubject` / `CurrentValueSubject` instances are stored as +non-`Sendable` properties of types that are not actor-isolated. This is the +single biggest blocker to honest `Sendable` conformance: even if we fix +Cluster 1 with an actor, the public surface still leaks non-Sendable Combine +types. + +`PeripheralDiscoveryEvent` cannot itself be `Sendable` because it stores +`advertisementData: [String: Any]` (`PeripheralDiscoveryEvent.swift:49`). + +### Cluster 3 — `@unchecked Sendable` data classes with foreign types +`Peripheral` (`Peripheral.swift:42`) and `PeripheralManager` +(`PeripheralManager.swift:30`) both use `@unchecked Sendable` to silence the +compiler while exposing non-Sendable types: +- `Peripheral.peripheral: CBPeripheral?` — `CBPeripheral` is not `Sendable`. +- `Peripheral.advertisementData: [String: Any]?` — `[String: Any]` is not + `Sendable`. +- `Peripheral.serviceUUIDs: [CBUUID]?` — `CBUUID` is a foreign reference type + with no `Sendable` annotation. + +`PeripheralManager`'s `queue.sync` does provide real serialization (the pair +verified this — it's not "lucky single-producer"), but the type is still +storing a non-`Sendable` `PassthroughSubject`. `Peripheral`'s manual `NSLock` +gives runtime safety per-property but not atomicity across reads (the `name` +getter acquires the lock twice; nothing prevents another writer interleaving +between the two reads). The comments in `Peripheral.swift` claiming "no lock +needed here" on certain computed properties are **misleading** — those +properties do acquire the lock indirectly via other getters. + +### Cluster 4 (subordinate) — Logging stack pinned to non-`Sendable` Willow +*(Resolved by commit `3657f45` — Willow 7.0 upgrade.)* + +**Historical framing (pre-Willow-7.0):** `LoggingService` was `Sendable` but +wrapped Willow's `Logger`, which was not `Sendable` upstream. Every +`import Willow` in the library carried `@preconcurrency`. `LogMessage.attributes: +[String: Any]` blocked `Sendable` conformance. `OSLogWriter` wrapped `os.OSLog` +without `Sendable` annotation. `ReliaBLEConfig` transitively held non-`Sendable` +Willow types. + +**Current state (post-Willow-7.0):** Willow 7.0 makes `Logger: @unchecked +Sendable` (with documented `ExecutionMethod.perform()` invariant), and refines +`LogMessage`, `LogWriter`, `LogModifierWriter`, `LogModifier`, `LogFilter`, +`LogLevel`, and `LogSource` to `Sendable`. The repo's `3657f45` merge removed +every `@preconcurrency import Willow`, updated `LogMessage.attributes` to +`[String: any Sendable]`, made `OSLogWriter` `final`, and added `Sendable` to +`LogTag`/`LogTag.Category`. Only three nits remain: + +1. **`LoggingService.enabled` is a racy `Bool`** (`LoggingService.swift:43`). + This is *deliberately* inherited from Willow's upstream design — Willow 7.0 + documents the `Logger.enabled` race as out-of-scope for its Sendable + migration. ReliaBLE mirrors that choice. Two actors toggling + `loggingService.enabled` concurrently is a benign race (last write wins, + reads may briefly observe stale value). **Recommendation: document the + behavior in DocC and move on** — or move `enabled` inside `BluetoothActor` + if a stricter contract is desired. +2. **`OSLogWriter` (`LogWriters.swift:47`)** is declared `public final class + OSLogWriter: LogModifierWriter` without an explicit `Sendable` conformance. + Since `LogModifierWriter` now refines `Sendable`, the compiler will + synthesize conformance if all stored properties (`subsystem: String`, + `category: String`, `modifiers: [LogModifier]`, `log: OSLog`) are `Sendable`. + `OSLog` *is* `Sendable` on iOS 16+ (Apple annotated it in the SDK), so + synthesis succeeds. **Verify with a clean build under + `-strict-concurrency=complete`;** if Apple ever rolls back `OSLog`'s + `Sendable`, add `@unchecked Sendable` (matches upstream `Willow.OSLogWriter`). +3. **`ReliaBLEConfig` (`ReliaBLEConfig.swift:33`)** doesn't have an explicit + `Sendable` conformance. All its members are now `Sendable` (`LogLevel`, + `[LogWriter]`, `DispatchQueue`, `Bool`), so the compiler synthesizes + `Sendable` for the public struct, but explicit conformance is preferable + for a public API surface — **add `public struct ReliaBLEConfig: Sendable`**. + +### Architectural conclusion + +These four clusters cannot be fixed incrementally without producing a worse +intermediate state. The audit recommends a single coordinated rewrite of the +core, sequenced to keep the build green at each step (see Recommendations, +Migration Order). + +--- + +## Recommendations + +### Chosen architecture + +**Global actor + delegate shim + AsyncStream broadcaster + nonisolated +`Sendable` façade.** + +This is the right shape for a greenfield iOS 18+ Swift 6.1 BLE library. It +yields true compile-time isolation, eliminates every `@unchecked Sendable`, +removes Combine from the public surface, and — critically — works equally +well from `@MainActor` SwiftUI code and from background actors, with no +forced MainActor hop. + +#### Isolation graph + +| Component | Isolation | Responsibility | +|---|---|---| +| `@globalActor BluetoothActor` | global actor | Owns `CBCentralManager`, peripheral registry, scanning state, all delegate callbacks. Single source of truth. | +| `BluetoothDelegateShim` | `nonisolated final class : NSObject` | Adopts `CBCentralManagerDelegate`. Each callback hops to `BluetoothActor` via `Task { @BluetoothActor in … }`. | +| `ReliaBLEManager` | **nonisolated** `final class`, `Sendable` | Thin public façade. Forwards `async` calls to `BluetoothActor.shared`. Exposes `AsyncStream` event surfaces. | +| `Peripheral` | `struct`, `Sendable` | Pure-value snapshot. No `CBPeripheral` reference. | +| `LoggingService` | `final class`, `Sendable` | Wraps `os.Logger` directly (Willow dropped). | + +Why nonisolated `ReliaBLEManager` and **not** `@MainActor` / `@Observable`: +the user's brief explicitly states main-actor access must be supported but +not required. A `@MainActor` façade would force every background-actor +caller to hop MainActor → BluetoothActor for every call — defeating the +purpose. SwiftUI consumers integrate via `.task { for await … }`, which is +the idiomatic iOS 18 pattern; observation/`@Observable` integration belongs +in *consumer* view-models, not the library. + +#### Delegate shim (code shape) + +```swift +@globalActor +public actor BluetoothActor { + public static let shared = BluetoothActor() +} + +final class BluetoothDelegateShim: NSObject, CBCentralManagerDelegate { + nonisolated let onStateUpdate: @Sendable (CBCentralManager) -> Void + nonisolated let onDiscover: @Sendable (CBPeripheral, [String: Any], NSNumber) -> Void + // ... + + nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) { + onStateUpdate(central) + } + nonisolated func centralManager(_ c: CBCentralManager, + didDiscover p: CBPeripheral, + advertisementData ad: [String: Any], + rssi: NSNumber) { + onDiscover(p, ad, rssi) + } +} +``` + +The shim's callbacks capture `@Sendable` closures that immediately +`Task { @BluetoothActor in … }` into the actor's internal handlers. The +shim itself owns no state and is `nonisolated final class`. Its closures +are constructed by the actor and capture an unowned/weak reference to it +to avoid retain cycles. + +#### AsyncStream broadcaster (in-actor) + +Each event surface (`state`, `peripheralDiscoveries`, `discoveredPeripherals`) +is implemented via a tiny **in-actor broadcaster**: + +```swift +extension BluetoothActor { + // Stored as actor state: + var stateContinuations: [UUID: AsyncStream.Continuation] = [:] + var currentBluetoothState: BluetoothState = .unknown + + nonisolated func stateStream() -> AsyncStream { + AsyncStream { continuation in + let id = UUID() + Task { @BluetoothActor in + continuation.yield(currentBluetoothState) // replay snapshot + stateContinuations[id] = continuation + continuation.onTermination = { _ in + Task { @BluetoothActor in stateContinuations[id] = nil } + } + } + } + } + + func broadcastState(_ new: BluetoothState) { + currentBluetoothState = new + for c in stateContinuations.values { c.yield(new) } + } +} +``` + +Each subscriber gets its own `AsyncStream` instance with replay of the +latest snapshot. No external dependencies. The same pattern handles +`peripheralDiscoveries` (no replay — pure event stream) and +`discoveredPeripherals` (replay with current list). Volume on all three is +low (BLE advertisement rate is bounded by iOS at single-digit Hz). + +#### `Peripheral` redesign (value type, no `CBPeripheral`) + +```swift +public struct Peripheral: Sendable, Identifiable, Hashable { + public let id: String // app-supplied or CB UUID string + public let cbIdentifier: UUID? // CB's own identifier (for retrieval) + public let name: String? + public let rssi: Int? + public let lastSeen: Date? + public let advertisement: AdvertisementData +} + +public struct AdvertisementData: Sendable, Hashable { + public let localName: String? + public let serviceUUIDs: [CBUUID] // CBUUID is value-like; mark @retroactive Sendable in an extension + public let manufacturerData: Data? + public let txPowerLevel: Int? + public let isConnectable: Bool? + public let serviceData: [CBUUID: Data] + public let overflowServiceUUIDs: [CBUUID] + public let solicitedServiceUUIDs: [CBUUID] +} +``` + +`[String: Any]` is removed from the public surface entirely. The actor +performs the known-key extraction once at discovery time. The mutable +`CBPeripheral` reference stays **inside** `BluetoothActor` (in a +`[String: CBPeripheral]` registry), and never escapes. + +> **Open question — unknown advertisement keys.** Some integrators will need +> access to manufacturer-specific or vendor-extension fields that the +> strongly-typed `AdvertisementData` doesn't enumerate. **Decision deferred +> to deeper planning before implementation** (see the sub-issue for Step 2). +> Candidate approaches: +> 1. Add `rawAdvertisement: [String: any Sendable]` as a secondary, +> explicitly-typed escape hatch on `AdvertisementData` — preserves the +> typed surface for known keys while exposing the long tail. +> 2. Provide a `manufacturerData(forCompanyID:)` -style accessor that does +> typed parsing of the well-known unstructured fields. +> 3. Keep `[String: any Sendable]` as the *only* surface and drop the +> typed-helper struct. +> Recommend #1 (typed convenience + raw escape hatch); finalize during +> implementation. + +Operations against a peripheral go through the manager by id: + +```swift +public func connect(to peripheral: Peripheral) async throws { ... } +public func disconnect(_ peripheral: Peripheral) async throws { ... } +``` + +If the integrating app holds a stale `Peripheral` snapshot whose id is no +longer in the actor's registry (e.g. after a Bluetooth power-cycle +invalidation), the operation throws an explicit `PeripheralError.notFound`. +Rationale: the caller already knows the stable `id`, so explicit failure +makes the contract obvious; auto-rescanning would hide stale-snapshot +issues, and returning `nil`/optional pushes nil-handling onto every call +site. + +#### `ReliaBLEManager` façade (nonisolated, Sendable) + +```swift +public final class ReliaBLEManager: Sendable { + public let loggingService: LoggingService + + public init(config: ReliaBLEConfig = .init()) { + loggingService = LoggingService(config: config) + Task { @BluetoothActor in + await BluetoothActor.shared.configure(logging: loggingService) + } + } + + // Event surfaces (each call returns a fresh subscriber stream) + public var state: AsyncStream { BluetoothActor.shared.stateStream() } + public var peripheralDiscoveries: AsyncStream { BluetoothActor.shared.discoveryStream() } + public var discoveredPeripherals: AsyncStream<[Peripheral]> { BluetoothActor.shared.peripheralsStream() } + + // Snapshot reads + public var currentState: BluetoothState { + get async { await BluetoothActor.shared.currentBluetoothState } + } + + // Actions + public func authorizeBluetooth() async throws { try await BluetoothActor.shared.authorize() } + public func startScanning(services: [CBUUID]? = nil) async { await BluetoothActor.shared.startScanning(services: services) } + public func stopScanning() async { await BluetoothActor.shared.stopScanning() } + public func connect(to peripheral: Peripheral) async throws { try await BluetoothActor.shared.connect(id: peripheral.id) } +} +``` + +A SwiftUI consumer typically wraps this in their own `@Observable @MainActor` +view-model, which spawns `.task { for await … in manager.state }` loops and +republishes via `@Published`-equivalent properties. The Demo's +`CentralViewModel` becomes a thin adapter. + +### B. Peripheral redesign + +Covered above. Key points: +- Pure `Sendable` `struct` — no manual locking, no `@unchecked Sendable`. +- Strongly-typed `AdvertisementData` replaces `[String: Any]`. Add a + `rawAdvertisement: [String: any Sendable]` only if the maintainer wants + to expose unknown keys (recommend: do not, for now). +- `CBPeripheral` lives only inside `BluetoothActor`'s registry. +- Equality / hashing on `id` is unchanged (already correct). +- `Peripheral` keeps `Identifiable, Hashable` for SwiftUI `ForEach` use. + +### C. Logging layer — keep Willow 7.0, polish remaining nits + +> **Revised post-Willow-7.0 (commit `3657f45`).** The original recommendation +> here was to drop Willow in favor of `os.Logger` directly, on the grounds +> that Willow's non-`Sendable` `Logger` was forcing `@preconcurrency` imports +> throughout the library. **That motivation no longer applies.** Willow 7.0 +> is Swift 6 / strict-concurrency clean upstream and the repo has already +> integrated it (`@preconcurrency` removed from all 4 sites; `LogMessage` +> updated to `[String: any Sendable]`). Keep Willow — it's pulling its +> weight, the integration cost is now zero, and dropping it would be +> churn for churn's sake. + +The remaining work in this layer is small polish: + +1. **Add explicit `Sendable` to `ReliaBLEConfig`** (`ReliaBLEConfig.swift:33`). + Compiler-synthesized, but explicit is better for a public struct that + will cross actor boundaries: + ```swift + public struct ReliaBLEConfig: Sendable { ... } + ``` + +2. **Verify `OSLogWriter` synthesizes `Sendable` cleanly** under + `-strict-concurrency=complete`. If it doesn't (e.g., on a future SDK + where `OSLog`'s Sendable status changes), follow upstream Willow's lead + and add `@unchecked Sendable`: + ```swift + public final class OSLogWriter: LogModifierWriter, @unchecked Sendable { ... } + ``` + The class is `final` with all `let` stored properties, so this is safe. + +3. **`LoggingService.enabled` race — leave as-is in ReliaBLE.** The user + requirement is that consuming apps can toggle logging at runtime, and + the last-write-wins behavior is acceptable. A separate GitHub issue + has been filed against `itsniper/Willow` to tighten the upstream + `Logger.enabled` contract in a future Willow release; ReliaBLE will + inherit the fix transparently. **No work in ReliaBLE.** + +No other changes are needed in the logging layer. The `LogTag` / `LogMessage` +/ `LogWriter` / `OSLogWriter` types are already in good shape post-Willow-7.0. + +### D. Migration order (build-green at every step) + +> **Revised post-Willow-7.0.** Step 1 ("Drop Willow → `os.Logger`") is gone +> — Willow 7.0 is already integrated. The logging-polish work folds into a +> lightweight final step. The remaining steps are unchanged. + +Each phase is a separately-mergeable PR. The build compiles and the test +suite runs after each. + +1. **Introduce `@globalActor BluetoothActor` + delegate shim.** Move all + `BluetoothManager` mutable state inside the actor. Keep public Combine + surface unchanged for now (wrapped in `@unchecked Sendable` where + needed to silence transitional warnings). All delegate callbacks now + route through the actor. Fixes Cluster 1. +2. **`Peripheral` → value struct + `AdvertisementData`.** Move + `CBPeripheral` registry inside `BluetoothActor`. Update + `PeripheralManager` (which becomes internal to the actor). Demo must + adapt: it currently reads `Peripheral.id`, `.name`, `.lastSeen` — + preserved. Fixes Cluster 3. +3. **`AnyPublisher` → `AsyncStream` broadcaster.** Replace all three event + surfaces. Remove `Combine` import from `BluetoothManager` / the actor / + `ReliaBLEManager`. Update Demo's `CentralViewModel` to use + `.task { for await … }`. Fixes Cluster 2. +4. **Make `ReliaBLEManager` nonisolated `Sendable` final class** and + collapse the now-internal `BluetoothManager` / `PeripheralManager` + types into the actor proper (or rename `BluetoothManager` → + `BluetoothActor` with internal helper types). Delete dead code + (`testFunction()` at `ReliaBLEManager.swift:106`, + `AuthorizationError.unauthorized` if unused, the `forceMock: true` + pass-through is **kept** per project rules). +5. **Logging polish + final pass.** Add explicit `Sendable` conformance to + `ReliaBLEConfig`. Verify `OSLogWriter` synthesizes `Sendable` (add + `@unchecked Sendable` if not, matching upstream `Willow.OSLogWriter`). + Enable `swift-version 6` build with `-strict-concurrency=complete` + everywhere; turn on the experimental `StrictConcurrency` upcoming + feature in `Package.swift` if not already. Update DocC catalog with the + new public surface (architecture & concurrency contract). Update + `Tests/ReliaBLETests` and the Demo end-to-end. The `Logger.enabled` + race is being addressed upstream in Willow — no DocC note needed in + ReliaBLE. + +### E. Risks / open questions for the maintainer + +1. **Demo will need to be rewritten** (Combine → async). It's the only + in-repo consumer and the user accepted breaking changes; flag it + anyway. Estimate: ~50 lines of `.task { for await … }` patterns + replacing the existing `.sink { }` pipelines. +2. **`CBUUID` is not declared `Sendable` upstream.** Need a + `extension CBUUID: @retroactive @unchecked Sendable {}` declaration + somewhere in the library, with a comment explaining why + (`CBUUID` is effectively immutable after init). +3. **`LoggingService.enabled` is a deliberate lock-free race** inherited + from Willow 7.0 upstream (Willow documents this as out-of-scope for its + Sendable migration). Acceptable for a logging on/off toggle; benign + last-write-wins behavior. Decision: **leave as-is in ReliaBLE.** A + separate upstream issue has been filed against `itsniper/Willow` to + tighten the contract; ReliaBLE will inherit the fix transparently when + it lands. +4. **Whether to keep a Combine bridge for one release.** Recommended + answer: **no**. Pre-1.0, no external consumers, dead weight that + re-introduces `@preconcurrency`. If wanted later, ship a thin extension + package (`ReliaBLECombine`) that adapts `AsyncStream` to Combine. +5. **Multi-subscriber semantics.** Each `manager.state` + property access returns a *new* `AsyncStream`. This is intentional and + correct (different SwiftUI views can each open one), but document it + prominently — accidental "I'll subscribe once and reuse" assumptions + will lead to confusion. +6. **`forceMock: true` parameter retention.** Per CLAUDE.md, this stays + as a literal `true` at the call site to the factory. The audit + confirms it remains load-bearing for the mock target after this + refactor; it just lives inside the actor's `setupCentralManager` now. +7. **Naming.** `BluetoothActor` vs. `BLEActor` vs. `ReliaBLEActor`. + Recommend `BluetoothActor` (clearest, namespace-disambiguated by + module). `BluetoothManager` is renamed away — its responsibilities + collapse into the actor; keep the file but rename the type, so DocC + anchor links can be updated cleanly. + +--- + +## Preventive Measures + +- **Enable complete strict-concurrency** in `Package.swift` for both + `ReliaBLE` and `ReliaBLEMock` targets: + ```swift + .target( + name: "ReliaBLE", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), + .swiftLanguageMode(.v6), + ] + ) + ``` + This catches regressions at compile time before review. +- **Ban `@unchecked Sendable` and `@preconcurrency import` via SwiftLint** + outside of `Sources/ReliaBLEMock/` (where mock aliases may legitimately + need a single `@retroactive` extension). SwiftLint's `custom_rules` + feature handles this cleanly: + ```yaml + custom_rules: + no_unchecked_sendable: + name: "@unchecked Sendable banned" + regex: '@unchecked\s+Sendable' + included: "Sources/ReliaBLE/.*\\.swift" + severity: error + no_preconcurrency_import: + name: "@preconcurrency import banned" + regex: '@preconcurrency\s+import' + included: "Sources/ReliaBLE/.*\\.swift" + severity: error + ``` + Wire SwiftLint into CI (see NFR 11 issue) so violations break the build, + not review. +- **Cross-actor validation in the Demo.** Update the Demo app to perform + SwiftData writes from a background actor (rather than `@MainActor`), so + the Demo itself exercises the library across actor boundaries. This + catches `@MainActor`-creep regressions earlier than the smoke test and + doubles as a concrete pattern for library consumers to copy. +- **Document the concurrency contract in DocC** under + `Documentation.docc/Concurrency.md` (new): "`ReliaBLEManager` is + `Sendable` and callable from any actor. All event surfaces return fresh + per-subscriber `AsyncStream`s. All actions are `async`. SwiftUI + consumers should consume via `.task { for await … }`." +- **Add a smoke test** that constructs a `ReliaBLEManager` from a + background actor and exercises every public method — guards against + inadvertent `@MainActor` regressions. +- **Add a `Peripheral` Sendable conformance test** that captures the + struct in a `Task.detached` closure (this fails to compile if the + struct accidentally regains a non-`Sendable` field). +- **Pin CoreBluetoothMock minor version** in `Package.swift` + (`upToNextMinor` already set) — any future mock API drift around + concurrency annotations should be flagged in a controlled bump. +- **DocC build in CI** (`swift package generate-documentation`) so public + API renames break the docs build, not silent drift. Tracked under the + CI parent issue (NFR 11). + +--- + +## Tracking — GitHub Issues + +Parent: [**#10 — Update for Swift Concurrency in Swift 6**](https://github.com/Five3Apps/ReliaBLE/issues/10) + +| Migration step | Issue | Status | +|---|---|---| +| Step 1 — `@globalActor BluetoothActor` + delegate shim | [#13](https://github.com/Five3Apps/ReliaBLE/issues/13) (reused) | Open | +| Step 2 — `Peripheral` → `Sendable` value struct + `AdvertisementData` | [#17](https://github.com/Five3Apps/ReliaBLE/issues/17) | Open | +| Step 3 — `AnyPublisher` → `AsyncStream` broadcaster | [#12](https://github.com/Five3Apps/ReliaBLE/issues/12) (reused) | Open | +| Step 4 — `ReliaBLEManager` → nonisolated `Sendable` + rename `BluetoothManager` → `BluetoothActor` | [#18](https://github.com/Five3Apps/ReliaBLE/issues/18) | Open | +| Step 5 — Logging polish + strict-concurrency flag flip + DocC update | [#19](https://github.com/Five3Apps/ReliaBLE/issues/19) | Open | +| Demo — exercise ReliaBLE from a background-actor SwiftData stack | [#20](https://github.com/Five3Apps/ReliaBLE/issues/20) | Open | + +Standalone (not under #10): + +| Track | Issue | +|---|---| +| NFR 11 — CI (GitHub Actions) parent | [#21](https://github.com/Five3Apps/ReliaBLE/issues/21) | +| ↳ DocC build in CI | [#22](https://github.com/Five3Apps/ReliaBLE/issues/22) | + +Upstream: + +| Track | Issue | +|---|---| +| Willow `Logger.enabled` data race (deferred from 7.0) | [itsniper/Willow#3](https://github.com/itsniper/Willow/issues/3) | From 3d042283b3af911991461edec92ee6695b08f8bb Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Fri, 26 Sep 2025 22:52:22 -0600 Subject: [PATCH 08/27] Updated peripheral and logging to Swift Concurrency --- Sources/ReliaBLE/Logging/LoggingService.swift | 2 +- Sources/ReliaBLE/Models/Peripheral.swift | 95 +++++++++++++------ Sources/ReliaBLE/PeripheralManager.swift | 6 +- 3 files changed, 71 insertions(+), 32 deletions(-) diff --git a/Sources/ReliaBLE/Logging/LoggingService.swift b/Sources/ReliaBLE/Logging/LoggingService.swift index c211828..3955960 100644 --- a/Sources/ReliaBLE/Logging/LoggingService.swift +++ b/Sources/ReliaBLE/Logging/LoggingService.swift @@ -27,7 +27,7 @@ import Foundation import Willow /// Service for managing all logging within ReliaBLE. -public class LoggingService { +public final class LoggingService: Sendable { let willowLogger: Logger init(levels: LogLevel, writers: [LogWriter], queue: DispatchQueue) { diff --git a/Sources/ReliaBLE/Models/Peripheral.swift b/Sources/ReliaBLE/Models/Peripheral.swift index ede255d..c0be7de 100644 --- a/Sources/ReliaBLE/Models/Peripheral.swift +++ b/Sources/ReliaBLE/Models/Peripheral.swift @@ -35,36 +35,72 @@ import Foundation /// A `Peripheral` can exist without a `CBPeripheral` but all `CBPeripherals` will have a corresponding `Peripheral`. /// This allows the integrating app to request communiication with a peripheral prior to it having been discovered /// by CoreBluetooth, or if CoreBluetooth has invalidated all of its `CBPeripherals`. -public class Peripheral: Identifiable, Hashable { +public final class Peripheral: Identifiable, Hashable, @unchecked Sendable { + private let mutationLock = NSLock() + /// Unique identifier for the peripheral as set by the integrating app. public let id: String /// The CoreBluetooth peripheral identifier, used to retrieve the peripheral after invalidation. - var peripheralIdentifier: UUID? + var peripheralIdentifier: UUID? { + // No lock needed here since this is a computed property that accesses `peripheral` + // which already handles its own synchronization. + + // If we have a valid CBPeripheral, return its identifier. + return peripheral?.identifier + } + private var _peripheral: CBPeripheral? /// Reference to the CoreBluetooth peripheral object /// /// - Warning: Intgrating app should not hold a strong reference! - var peripheral: CBPeripheral? + var peripheral: CBPeripheral? { + mutationLock.lock(); defer { mutationLock.unlock() } + + return _peripheral + } /// The name advertised by the peripheral, if available public var name: String? { - peripheral?.name ?? advertisementData?[CBAdvertisementDataLocalNameKey] as? String + // No lock needed here since this is a computed property that accesses `peripheral` and `advertisementData` + // which already handle their own synchronization. + return peripheral?.name ?? advertisementData?[CBAdvertisementDataLocalNameKey] as? String } /// Advertised service UUIDs public var serviceUUIDs: [CBUUID]? { + // No lock needed here since this is a computed property that accesses `advertisementData` + // which already handles its own synchronization. advertisementData?[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] } + private var _rssi: Int? /// Signal strength indicator (RSSI) of the most recent advertisement - public internal(set) var rssi: Int? + public var rssi: Int? { + mutationLock.lock(); defer { mutationLock.unlock() } + + // Return the cached RSSI value if available. + return _rssi + } + private var _advertisementData: [String: Any]? /// Complete advertisement data dictionary from the most recent discovery - public internal(set) var advertisementData: [String: Any]? + public var advertisementData: [String: Any]? { + mutationLock.lock(); defer { mutationLock.unlock() } + + // Return the cached advertisement data if available. + return _advertisementData + + } + private var _lastSeen: Date? /// The timestamp when the peripheral was last seen - public internal(set) var lastSeen: Date? + public var lastSeen: Date? { + mutationLock.lock(); defer { mutationLock.unlock() } + + // Return the cached last seen date if available. + return _lastSeen + } /// Creates a peripheral with a unique identifier and optional CoreBluetooth discovery data. /// @@ -77,45 +113,48 @@ public class Peripheral: Identifiable, Hashable { /// - rssi: Signal strength indicator (RSSI) of the most recent advertisement public init(id: String, peripheral: CBPeripheral? = nil, advertisementData: [String: Any]? = nil, rssi: Int? = nil) { self.id = id - self.peripheralIdentifier = peripheral?.identifier - self.peripheral = peripheral - self.rssi = rssi - self.advertisementData = advertisementData + + mutationLock.name = "Peripheral-\(id)-MutationLock" + + _peripheral = peripheral + _rssi = rssi + _advertisementData = advertisementData if peripheral != nil && rssi != nil { // Only set the last seen date if we have a valid CBPeripheral and RSSI value. // This indicates that we have received an advertisement from the peripheral. - self.lastSeen = Date() + _lastSeen = Date() } } func update(cbPeripheral: CBPeripheral, advertisementData: [String: Any]? = nil, rssi: Int? = nil) { - self.peripheralIdentifier = cbPeripheral.identifier - self.peripheral = cbPeripheral - self.advertisementData = advertisementData - self.rssi = rssi + mutationLock.lock(); defer { mutationLock.unlock() } + + _peripheral = cbPeripheral + _advertisementData = advertisementData + _rssi = rssi if rssi != nil { // Only set the last seen date if we have a valid RSSI value. // This indicates that we have received an advertisement from the peripheral. - self.lastSeen = Date() + _lastSeen = Date() } } - public func hash(into hasher: inout Hasher) { + /// Invalidates the CoreBluetooth peripheral reference + func invalidateCBPeripheral() { + mutationLock.lock(); defer { mutationLock.unlock() } + + _peripheral = nil + } + + nonisolated public func hash(into hasher: inout Hasher) { hasher.combine(id) } public static func == (lhs: Peripheral, rhs: Peripheral) -> Bool { - // Original implementation: lhs.id == rhs.id - - // Two peripherals should be considered equal if they have the same identifier. However, - // I have seen edge cases where the identifier did not change for a new CBPeripheral instance. - // https://developer.apple.com/forums/thread/742497 - if lhs.id != rhs.id { - return false - } - - return lhs.peripheral === rhs.peripheral + // No need to compare `CBPeripheral` objects or identifiers, since the `id` is unique and matching between + // `Peripheral` and `CBPeriphal` instances are handled internally. + return lhs.id == rhs.id } } diff --git a/Sources/ReliaBLE/PeripheralManager.swift b/Sources/ReliaBLE/PeripheralManager.swift index 0d7ce70..dfc6c9a 100644 --- a/Sources/ReliaBLE/PeripheralManager.swift +++ b/Sources/ReliaBLE/PeripheralManager.swift @@ -27,7 +27,7 @@ import Combine import CoreBluetooth -class PeripheralManager { +final class PeripheralManager: @unchecked Sendable { private let log: LoggingService private var discoveredPeripherals = [Peripheral]() @@ -48,7 +48,7 @@ class PeripheralManager { let identifier = cbPeripheral.name ?? advertisementData?[CBAdvertisementDataLocalNameKey] as? String ?? cbPeripheral.identifier.uuidString queue.sync { - // First check if the peripheral has already been discovered by identifier + // First check if the peripheral has already been discovered by app-provided identifier if let existingPeripheral = discoveredPeripherals.first(where: { $0.id == identifier }) { existingPeripheral.update(cbPeripheral: cbPeripheral, advertisementData: advertisementData, rssi: rssi) discoveredPeripheralsSubject.send(discoveredPeripherals) @@ -72,7 +72,7 @@ class PeripheralManager { func invalidatePeripherals() { queue.sync { for peripheral in discoveredPeripherals { - peripheral.peripheral = nil + peripheral.invalidateCBPeripheral() } discoveredPeripheralsSubject.send(discoveredPeripherals) log.debug("Invalidated all peripheral references") From c87c683e9b24c4100220fc4ee52e2058909c90b4 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Mon, 8 Jun 2026 18:30:36 -0600 Subject: [PATCH 09/27] Updates for AI coding agents --- AGENTS.md | 50 +++++++++++++++++++++ CLAUDE.md | 1 + Demo/.xcodebuildmcp/config.yaml | 22 +++++++++ Demo/AGENTS.md | 67 ++++++++++++++++++++++++++++ Demo/CLAUDE.md | 1 + agents/skills/xcodebuildmcp/SKILL.md | 41 +++++++++++++++++ opencode.json | 14 ++++++ 7 files changed, 196 insertions(+) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 Demo/.xcodebuildmcp/config.yaml create mode 100644 Demo/AGENTS.md create mode 120000 Demo/CLAUDE.md create mode 100644 agents/skills/xcodebuildmcp/SKILL.md create mode 100644 opencode.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9e263b2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,50 @@ +# AGENTS.md + +This file provides guidance to AI Agents (Claude Code, Codex, Grok Build, OpenCode, etc.) when working with code in this repository. + +## Repository layout + +Two related projects share this repo: + +- **ReliaBLE library** (this directory) — Swift Package at `Package.swift` / `Sources/` / `Tests/`. swift-tools-version 6.1, iOS 18+ / macOS 10.15+, builds under **Swift 6 with complete concurrency checking**. This is the supported, shipped product. +- **Demo app** — `Demo/ReliaBLE Demo/`, consumes the library locally. Has its own `Demo/CLAUDE.md` with different conventions (looser concurrency, exploratory code). Treat it as a separate project — don't carry its patterns back into the library. + +Open `ReliaBLE.xcworkspace` at the root to work on both together. + +## Build, test + +```sh +swift build # build all targets +swift test # run ReliaBLETests +swift test --filter ReliaBLETests.correctFunction # single test +``` + +## Architecture + +### Three-target SPM trick for CoreBluetooth mocking + +The package declares **three targets** that share a single source tree to make `CoreBluetooth` mockable in tests without polluting the production binary: + +- `ReliaBLE` — production target. Uses real `CoreBluetooth`. Includes `Sources/ReliaBLE/CBCentralManagerFactory.swift`, a thin enum that returns a real `CBCentralManager`. +- `ReliaBLEMock` — same sources as `ReliaBLE`, but **excludes** `CBCentralManagerFactory.swift` and the DocC catalog, and links `CoreBluetoothMock` (Nordic Semi). In `Sources/ReliaBLEMock/CoreBluetoothMockAliases.swift`, public typealiases rebind `CBCentralManager`, `CBPeripheral`, `CBCentralManagerFactory`, etc. to their `CBM*` mock counterparts. +- `ReliaBLETests` — depends on `ReliaBLEMock` (not `ReliaBLE`). + +**Consequence:** the library code only ever calls `CBCentralManagerFactory.instance(...)`, never `CBCentralManager(...)` directly. The factory's identity is swapped at compile time per target. When editing core BLE code, keep this constraint — `import CoreBluetooth` is fine, but instantiate the central via the factory. + +### Swift Concurrency + +The library is built with Swift 6 and **complete concurrency checking**. The `ReliaBLEManager` public API should be callable from `@MainActor`, but the library itself should avoid `@MainActor` and instead use `@BluetoothActor` (a custom actor defined in `BluetoothManager.swift`) to serialize all Bluetooth interactions. This keeps the library thread-safe and allows the integrating app to decide how to bridge to the main thread for UI updates. + +### Logging + +`LoggingService` wraps Willow's `Logger` with an async execution queue. The service is `Sendable` and passed by reference into both managers. Default writer is an `OSLogWriter` (`subsystem: com.five3apps.relia-ble`, `category: BLE`), configurable via `ReliaBLEConfig`. Logging is **disabled by default** — `config.loggingEnabled` must be set to true. Log calls take a `tags: [LogTag]` array; use `.category(.scanning)`, `.peripheral(id)`, etc. rather than embedding the category in the message. + +### Authorization flow + +`ReliaBLEManager.init` does **not** instantiate `CBCentralManager` unless the user has already granted `.allowedAlways`. This is deliberate so the integrating app controls when the iOS permission prompt appears — callers invoke `authorizeBluetooth()` when they want the prompt. Preserve this lazy-init behavior when touching `BluetoothManager.setupCentralManager()`. + +## Notes for editing + +- Public API on `ReliaBLEManager` is the supported surface for external consumers. Adding/removing methods there is a breaking change. +- `forceMock: true` is currently passed to `CBCentralManagerFactory.instance(...)` in `BluetoothManager`. The production factory ignores this parameter; the mock factory honors it. Don't "clean it up" — it's load-bearing for the test target. +- DocC catalog lives at `Sources/ReliaBLE/Documentation.docc/`. The `swift-docc-plugin` is a package dep so `swift package generate-documentation` works. This documentation **must** be kept up to date with the public API on `ReliaBLEManager` and the overall architecture and usage patterns. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Demo/.xcodebuildmcp/config.yaml b/Demo/.xcodebuildmcp/config.yaml new file mode 100644 index 0000000..f69820f --- /dev/null +++ b/Demo/.xcodebuildmcp/config.yaml @@ -0,0 +1,22 @@ +schemaVersion: 1 +enabledWorkflows: + - macos + - simulator + - coverage + - debugging + - device + - project-discovery + - swift-package + - xcode-ide +debug: false +sentryDisabled: false +sessionDefaults: + scheme: ReliaBLE Demo + simulatorName: iPhone 17 Pro Max + simulatorId: C0B74568-2B47-4981-ACA8-389277E1BB02 + deviceId: 3D0082F1-03D3-5FD1-8F46-55595E121B15 + workspacePath: /Users/jus10sb/Documents/Five3Apps/Projects/OpenSource/ReliaBLE/ReliaBLE.xcworkspace +setupPreferences: + platforms: + - iOS + - macOS diff --git a/Demo/AGENTS.md b/Demo/AGENTS.md new file mode 100644 index 0000000..e3b4eda --- /dev/null +++ b/Demo/AGENTS.md @@ -0,0 +1,67 @@ +# AGENTS.md + +This file provides guidance to AI Agents (Claude Code, Codex, Grok Build, OpenCode, etc.) when working with code in this repository. + +## What this project is + +The **ReliaBLE Demo** is a sample iOS app whose only purpose is to exercise and show off the ReliaBLE library that lives one directory up. It is not shipped, not a product, and not held to the same bar as the library: + +- **Looser style bar.** The target does build under Swift 6 complete concurrency checking, but `@Observable` view models, `DispatchQueue.main`-based Combine sinks, `try?` discards, and `print(...)` for error paths are all fine here. Don't port library patterns (actors, custom `LoggingService`, three-target mocking, etc.) into the demo unless they're needed to talk to the library's public API. +- **Exploratory code is welcome.** Quick wiring, hardcoded values, `TODO` stubs, and view-model shortcuts are acceptable as long as they demonstrate a library feature clearly. +- **Don't refactor demo code "for quality"** unless asked. If something looks wrong in the demo, it's often deliberately minimal. + +If you're working on a feature here and it forces you to also change the library, surface that — it usually means the library's public API needs work, which is a separate conversation. + +## Build / run + +Uses `CoreBluetooth` (central) and `CBPeripheralManager` (peripheral), plus SwiftData. The root workspace must be used — not the project file — so the local `ReliaBLE` package resolves correctly. + +**Preferred: XcodeBuildMCP** + +When using XcodeBuildMCP, use the installed XcodeBuildMCP skill before calling XcodeBuildMCP tools. Always verify defaults before the first build. + +``` +workspace: /path/to/ReliaBLE/ReliaBLE.xcworkspace +scheme: ReliaBLE Demo +``` + +- Simulator: `build_run_sim` (BLE radio unavailable — UI only) +- Device: `build_run_device` with `-allowProvisioningUpdates`; on first run the user must trust the developer certificate at **Settings → General → VPN & Device Management** + +Real device recommended for full BLE. The peripheral tab requires a device with a BLE radio to actually advertise. + +To open in Xcode manually: + +```sh +open ../ReliaBLE.xcworkspace # from the Demo/ dir +``` + +Scheme: **"ReliaBLE Demo"**. + +## App structure + +`ReliaBLE Demo.xcodeproj` lives at `Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj`. The app is a 3-tab SwiftUI app: + +- **Central** (`Central/`) — uses `ReliaBLEManager` (the library) to scan, surfaces discoveries into a SwiftData store. `CentralViewModel` is the only place the library's Combine publishers are subscribed. State observed via `@Observable`. +- **Peripheral** (`Peripheral/`) — uses raw `CBPeripheralManager` directly (not the library — the library doesn't yet expose peripheral mode). This is intentional; the demo shows both sides of BLE even though only the central side flows through ReliaBLE. +- **Settings** (`Settings/`) — app-level toggles. + +### How the library is wired in + +- `ReliaBLE_DemoApp.swift` constructs a single `ReliaBLEManager` with `loggingEnabled = true` and injects it via a custom `EnvironmentKey` (`@Environment(\.bleManager)`). +- There's also a `BLEManagerKey.defaultValue` for previews — keep that working when changing the env injection. +- A `ModelContainer` for `Device` and `DiscoveryEvent` is set on the root scene. `CentralViewModel` writes both kinds of records as the library emits events. + +## Swift Concurrency + +The library is built with Swift 6 and **complete concurrency checking**. Therefore, `ReliaBLE Demo` is also built with Swift 6 and complete concurrency checking. + +### SwiftData models + +- `Device` — one row per unique discovered peripheral (by `ReliaBLE.Peripheral.id`), updated on each `discoveredPeripherals` publisher tick. +- `DiscoveryEvent` — one row per raw advertisement (every `peripheralDiscoveries` event). Grows quickly; "Clear All Data" in the Central view nukes both tables. + +## Don't + +- Don't add the library's `CBCentralManagerFactory` indirection here — the demo imports `CoreBluetooth` directly for the peripheral side and that's fine. +- Don't replace `print(...)` debug calls in `CentralViewModel` with a logging service. The library has logging; the demo doesn't need one. diff --git a/Demo/CLAUDE.md b/Demo/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/Demo/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/agents/skills/xcodebuildmcp/SKILL.md b/agents/skills/xcodebuildmcp/SKILL.md new file mode 100644 index 0000000..5fc4740 --- /dev/null +++ b/agents/skills/xcodebuildmcp/SKILL.md @@ -0,0 +1,41 @@ +--- +name: xcodebuildmcp +description: Official skill for XcodeBuildMCP. Use when doing iOS/macOS/watchOS/tvOS/visionOS work (build, test, run, debug, log, UI automation). +--- + +# XcodeBuildMCP + +Use XcodeBuildMCP tools instead of raw `xcodebuild`, `xcrun`, or `simctl`. + +Capabilities: +- Session defaults: Configure project, scheme, simulator, and device defaults to avoid repetitive parameters +- Project discovery: Find Xcode projects/workspaces, list schemes, inspect build settings +- Simulator workflows: Build, run, test, install, and launch apps on iOS simulators; manage simulator state (boot, erase, location, appearance) +- Device workflows: Build, test, install, and launch apps on physical devices with code signing +- macOS workflows: Build, run, and test macOS applications +- Log capture: Stream and capture logs from simulators and devices +- LLDB debugging: Attach debugger, set breakpoints, inspect stack traces and variables, execute LLDB commands +- UI automation: Capture screenshots, inspect view hierarchy with coordinates, perform taps/swipes/gestures, type text, press hardware buttons +- SwiftPM: Build, run, test, and manage Swift Package Manager projects +- Project scaffolding: Generate new iOS/macOS project templates + +Only simulator workflow tools are enabled by default. If capabilities like device, macOS, debugging, or UI automation are not available, the user must configure XcodeBuildMCP to enable them. See https://xcodebuildmcp.com/docs/configuration for workflow configuration. + +## Step 1: Establish Session Context + +- Call `session_show_defaults` before the first build/run/test action in a session. +- Use `discover_projs` only when defaults show missing or incorrect project/workspace context. +- Do not run discovery speculatively or in parallel with `session_show_defaults`. +- For simulator run intent, prefer the combined build-and-run tool instead of separate build then run calls. +- Do not chain build-only then build-and-run unless the user explicitly requests both. + +## Step 2: Understand Workflow-Scoped Tool Availability + +- Not all tools are enabled by default; tool availability depends on enabled workflows. +- If a tool is expected but missing, check enabled workflows first. +- Update enabled workflows in `.xcodebuildmcp/config.yaml`, then ask user to reload/restart the session to surface refreshes. + +## Step 3: Report Context Clearly + +- Return the active defaults context used for execution (project/workspace, scheme, simulator/device). +- For failures, include the exact failing step and the next actionable command/tool call. diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..ef043e4 --- /dev/null +++ b/opencode.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "XcodeBuildMCP": { + "type": "local", + "command": ["xcodebuildmcp", "mcp"], + "environment": { + "MCP_TIMEOUT": "180000", + "MCP_TOOL_TIMEOUT": "10800000", + "MAX_MCP_OUTPUT_TOKENS": "50000" + } + } + } +} \ No newline at end of file From 80f0c04cb4f85cefad0605ffba0a55283280e579 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Thu, 11 Jun 2026 12:14:23 -0600 Subject: [PATCH 10/27] Introduce BluetoothActor to serialize CoreBluetooth state (Step 1 of 5, #13) Moves CBCentralManager, the discovered-peripherals list, and Combine subjects into a new @globalActor BluetoothActor, with a BluetoothDelegateShim bridging CBCentralManagerDelegate callbacks into actor-isolated handlers. BluetoothManager and ReliaBLEManager become thin async forwarders, PeripheralManager is folded into the actor, and docs/demo call sites are updated for the new async API. Co-Authored-By: Claude Sonnet 4.6 --- .../Central/CentralViewModel.swift | 6 +- Sources/ReliaBLE/BluetoothActor.swift | 361 ++++++++++++++++++ Sources/ReliaBLE/BluetoothManager.swift | 249 +++--------- .../Documentation.docc/Documentation.md | 4 + .../Documentation.docc/GettingStarted.md | 16 +- Sources/ReliaBLE/Models/Peripheral.swift | 8 +- Sources/ReliaBLE/PeripheralManager.swift | 101 ----- Sources/ReliaBLE/ReliaBLEManager.swift | 20 +- .../bluetooth-actor-migration-2026-06-08.md | 99 +++++ 9 files changed, 550 insertions(+), 314 deletions(-) create mode 100644 Sources/ReliaBLE/BluetoothActor.swift delete mode 100644 Sources/ReliaBLE/PeripheralManager.swift create mode 100644 docs/plans/bluetooth-actor-migration-2026-06-08.md diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift index 0a27c96..175702a 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift @@ -92,16 +92,16 @@ import ReliaBLE } func authorizeBluetooth() { - try? reliaBLE?.authorizeBluetooth() + Task { try? await reliaBLE?.authorizeBluetooth() } } func startScanning() { let services = parseServices(from: servicesInput) - reliaBLE?.startScanning(services: services) + Task { await reliaBLE?.startScanning(services: services) } } func stopScanning() { - reliaBLE?.stopScanning() + Task { await reliaBLE?.stopScanning() } } func clearAllData() { diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift new file mode 100644 index 0000000..3295289 --- /dev/null +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -0,0 +1,361 @@ +// +// BluetoothActor.swift +// ReliaBLE +// +// Created by Justin Bergen on 6/8/26. +// +// Copyright (c) 2026 Five3 Apps, LLC +// +// 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: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// 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. + +import Combine +import CoreBluetooth +import Foundation + +// MARK: - Sendable Bridging Helper + +/// Wraps a non-`Sendable` value for safe transfer across actor isolation boundaries. +/// +/// The caller must ensure the wrapped value is not mutated concurrently from multiple +/// isolation domains. This wrapper is used only for the transitional hop between +/// CoreBluetooth's delegate queue and ``BluetoothActor``. +/// +/// **TODO: removed in Step 2** when `Peripheral` becomes a `Sendable` value type. +private struct SendableWrapper: @unchecked Sendable { + let value: T +} + +// MARK: - BluetoothActor + +/// Process-wide global actor that serializes all CoreBluetooth interactions. +/// +/// All mutable BLE state—`CBCentralManager`, Combine subjects, and discovered +/// peripherals—are owned exclusively by this actor. Two `ReliaBLEManager` instances +/// share the same isolation domain; this is acceptable because CoreBluetooth already +/// enforces a single central manager per process. +/// +/// Delegate callbacks arrive on CoreBluetooth's internal queue and are hopped into this +/// actor's isolation via `Task { @BluetoothActor in … }` inside the nonisolated +/// ``BluetoothDelegateShim``. +@globalActor +public actor BluetoothActor { + + /// The process-lifetime shared instance. + public static let shared = BluetoothActor() + + // MARK: - Actor-Isolated State + + var centralManager: CBCentralManager? + private var delegateShim: BluetoothDelegateShim? + + var log: LoggingService? + + var discoveredPeripherals: [Peripheral] = [] + + // Combine subjects — written only from actor-isolated context. + let stateSubject = CurrentValueSubject(.unknown) + let discoverySubject = PassthroughSubject() + let discoveredPeripheralsSubject = PassthroughSubject<[Peripheral], Never>() + + // MARK: - nonisolated(unsafe) Bridging Properties + // + // Publishers are extracted once in `init` and never reassigned — safe for concurrent reads. + // `currentBluetoothState` is written only by the serial actor executor via + // `broadcastState(_:)` — safe for concurrent reads (value semantics, no partial writes). + // + // TODO: All removed in Step 3 when AnyPublisher is replaced by AsyncStream. + + /// Publisher for the real-time Bluetooth state. **TODO: removed in Step 3.** + nonisolated(unsafe) let statePublisher: AnyPublisher + + /// Publisher for peripheral discovery events. **TODO: removed in Step 3.** + nonisolated(unsafe) let discoveryPublisher: AnyPublisher + + /// Publisher for the current list of discovered peripherals. **TODO: removed in Step 3.** + nonisolated(unsafe) let discoveredPeripheralsPublisher: AnyPublisher<[Peripheral], Never> + + /// Synchronous snapshot of the current Bluetooth state. + /// + /// Written only from `broadcastState(_:)` on the actor's serial executor. + /// **TODO: removed in Step 3.** + nonisolated(unsafe) var currentBluetoothState: BluetoothState = .unknown + + // MARK: - Initialization + + private init() { + statePublisher = stateSubject.eraseToAnyPublisher() + discoveryPublisher = discoverySubject.eraseToAnyPublisher() + discoveredPeripheralsPublisher = discoveredPeripheralsSubject.eraseToAnyPublisher() + } + + // MARK: - Configuration + + func configure(log: LoggingService) { + self.log = log + } + + /// Configures the actor with a logger, conditionally sets up the central manager if + /// Bluetooth is already authorized, then broadcasts the initial state. Called once from + /// `BluetoothManager.init` via a fire-and-forget `Task`. + func initialize(log: LoggingService) { + configure(log: log) + if CBCentralManager.authorization == .allowedAlways { + setupCentralManager() + } + updateState() + } + + // MARK: - Central Manager Setup + + func setupCentralManager() { + guard centralManager == nil else { return } + + log?.info("Initializing CBCentralManager") + + let shim = BluetoothDelegateShim() + delegateShim = shim + // Use CBCentralManagerFactory for consistency between normal and test targets. + // `forceMock: true` is load-bearing for the ReliaBLEMock test target — do not remove. + centralManager = CBCentralManagerFactory.instance(delegate: shim, queue: nil, options: nil, forceMock: true) + } + + // MARK: - Authorization + + func authorize() throws { + log?.info("Authorizing bluetooth") + + switch CBCentralManager.authorization { + case .notDetermined: + setupCentralManager() + case .denied: + throw AuthorizationError.denied + case .restricted: + throw AuthorizationError.restricted + case .allowedAlways: + setupCentralManager() + @unknown default: + throw AuthorizationError.unknown + } + } + + // MARK: - Scanning + + func startScanning(services: sending [CBUUID]? = nil) { + guard let centralManager else { + log?.warn(tags: [.category(.scanning)], "Attempted to start scan without a central manager") + return + } + + guard centralManager.state == .poweredOn else { + log?.warn(tags: [.category(.scanning)], "Attempted to start scan while central manager is not ready (poweredOn)") + return + } + + centralManager.scanForPeripherals(withServices: services, options: nil) + + if centralManager.isScanning { + log?.info(tags: [.category(.scanning)], "Scanning started with services: \(services ?? [])") + updateState() + } else { + log?.warn(tags: [.category(.scanning)], "Failed to start scanning") + } + } + + func stopScanning() { + guard let centralManager else { + log?.warn(tags: [.category(.scanning)], "Attempted to stop scan without a central manager") + return + } + + centralManager.stopScan() + + if !centralManager.isScanning { + log?.info(tags: [.category(.scanning)], "Scanning stopped") + updateState() + } else { + log?.warn(tags: [.category(.scanning)], "Failed to stop scanning") + } + } + + // MARK: - State Management + + func updateState() { + switch CBCentralManager.authorization { + case .notDetermined: + broadcastState(.unauthorized(.notDetermined)) + return + case .denied: + broadcastState(.unauthorized(.denied)) + return + case .restricted: + broadcastState(.unauthorized(.restricted)) + return + default: + break + } + + // Check scanning before centralManager state — scanning implies poweredOn. + if centralManager?.isScanning == true { + broadcastState(.scanning) + return + } + + switch centralManager?.state { + case .poweredOn: + broadcastState(.ready) + case .poweredOff: + broadcastState(.poweredOff) + case .resetting: + broadcastState(.resetting) + case .unsupported: + broadcastState(.unsupported) + default: + broadcastState(.unknown) + } + } + + private func broadcastState(_ state: BluetoothState) { + stateSubject.send(state) + // nonisolated(unsafe) write — safe: written only from the actor's serial executor. + // TODO: removed in Step 3 + currentBluetoothState = state + } + + // MARK: - Delegate Entry Points (called by BluetoothDelegateShim) + + func handleCentralManagerStateUpdate() { + guard let centralManager else { return } + + log?.debug("centralManagerDidUpdateState: \(centralManager.state.rawValue)") + + switch centralManager.state { + case .poweredOn: + refreshPeripherals() + case .poweredOff, .unknown: + // These states do not invalidate peripherals. + break + case .resetting, .unsupported, .unauthorized: + invalidatePeripherals() + @unknown default: + log?.error("Unknown CBCentralManager state encountered: \(centralManager.state.rawValue)") + assertionFailure("Unknown CBCentralManager state encountered: \(centralManager.state.rawValue)") + } + + updateState() + } + + func handlePeripheralDiscovered( + _ cbPeripheral: CBPeripheral, + advertisementData: [String: Any], + rssi: Int + ) { + // Inlined rather than calling private helpers so that the non-Sendable + // CBPeripheral reference never appears at an actor method call site. + // The Swift 6 region-based isolation checker reports a false positive + // ("please file a bug") when a non-Sendable value is passed to an + // actor-isolated method, even from within the same actor. + // TODO: Step 2 — refactor once Peripheral is a Sendable value type. + + // Emit lightweight discovery feed. + // TODO: Implement verbose log level + discoverySubject.send( + PeripheralDiscoveryEvent(peripheral: cbPeripheral, advertisementData: advertisementData, rssi: rssi) + ) + + // TODO: FR-8.5: Unique Identifier from Manufacturing Data — connect to id once implemented + let identifier = cbPeripheral.name + ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String + ?? cbPeripheral.identifier.uuidString + + // Check if already discovered by app-provided identifier + if let existing = discoveredPeripherals.first(where: { $0.id == identifier }) { + existing.update(cbPeripheral: cbPeripheral, advertisementData: advertisementData, rssi: rssi) + discoveredPeripheralsSubject.send(discoveredPeripherals) + return + } + + // Check if already discovered by CBPeripheral identifier + if let existing = discoveredPeripherals.first(where: { $0.peripheralIdentifier == cbPeripheral.identifier }) { + existing.update(cbPeripheral: cbPeripheral, advertisementData: advertisementData, rssi: rssi) + discoveredPeripheralsSubject.send(discoveredPeripherals) + return + } + + let new = Peripheral(id: identifier, peripheral: cbPeripheral, advertisementData: advertisementData, rssi: rssi) + log?.debug(tags: [.category(.scanning), .peripheral(new.id)], "Adding newly discovered peripheral") + discoveredPeripherals.append(new) + discoveredPeripheralsSubject.send(discoveredPeripherals) + } + + private func invalidatePeripherals() { + for peripheral in discoveredPeripherals { + peripheral.invalidateCBPeripheral() + } + discoveredPeripheralsSubject.send(discoveredPeripherals) + log?.debug("Invalidated all peripheral references") + } + + private func refreshPeripherals() { + guard let centralManager else { return } + + let identifiers = discoveredPeripherals.compactMap { $0.peripheralIdentifier } + guard !identifiers.isEmpty else { + log?.debug("No peripheral identifiers to refresh") + return + } + + let retrieved = centralManager.retrievePeripherals(withIdentifiers: identifiers) + for cbPeripheral in retrieved { + if let p = discoveredPeripherals.first(where: { $0.peripheralIdentifier == cbPeripheral.identifier }) { + p.update(cbPeripheral: cbPeripheral) + } + } + discoveredPeripheralsSubject.send(discoveredPeripherals) + log?.debug("Refreshed \(retrieved.count) peripherals from CBCentralManager") + } +} + +// MARK: - BluetoothDelegateShim + +/// Bridges `CBCentralManagerDelegate` callbacks—which arrive on CoreBluetooth's internal +/// queue—into ``BluetoothActor``-isolated handlers via unstructured `Task` hops. +/// +/// The shim holds no mutable state. All meaningful work happens inside ``BluetoothActor``. +/// No weak/unowned reference is needed because ``BluetoothActor/shared`` is a +/// process-lifetime singleton. +final class BluetoothDelegateShim: NSObject, CBCentralManagerDelegate { + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + Task { await BluetoothActor.shared.handleCentralManagerStateUpdate() } + } + + func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + // Wrap non-Sendable values for the actor isolation hop. + // TODO: Step 2 — remove SendableWrapper when Peripheral becomes a Sendable value type. + let p = SendableWrapper(value: peripheral) + let ad = SendableWrapper(value: advertisementData) + let rssi = RSSI.intValue + Task { await BluetoothActor.shared.handlePeripheralDiscovered(p.value, advertisementData: ad.value, rssi: rssi) } + } +} diff --git a/Sources/ReliaBLE/BluetoothManager.swift b/Sources/ReliaBLE/BluetoothManager.swift index fdc7204..b6fd66a 100644 --- a/Sources/ReliaBLE/BluetoothManager.swift +++ b/Sources/ReliaBLE/BluetoothManager.swift @@ -28,15 +28,14 @@ import Combine import CoreBluetooth import Foundation -/// High-level manager for all Bluetooth operations. Manages the CBCentralManager and provides a single point of access -/// for all Bluetooth operations. -class BluetoothManager: NSObject, CBCentralManagerDelegate { +/// Internal intermediary that forwards BLE operations to ``BluetoothActor`` and exposes +/// synchronous `AnyPublisher` properties for consumption by ``ReliaBLEManager``. +/// +/// This class is removed in Step 4 when `ReliaBLEManager` collapses the indirection and +/// becomes `nonisolated Sendable`. +class BluetoothManager { private let log: LoggingService - private let peripheralManager: PeripheralManager - - private var centralManager: CBCentralManager? - private let queue = DispatchQueue(label: "com.five3apps.relia-ble.bluetoothmanager", qos: .userInitiated) - + // MARK: - Initialization /// Initializes the BluetoothManager with the provided LoggingService. Initializing a BluetoothManager does not @@ -47,201 +46,79 @@ class BluetoothManager: NSObject, CBCentralManagerDelegate { /// /// - Parameter loggingService: The LoggingService to use for logging. /// - Returns: A new instance of BluetoothManager. - init(loggingService: LoggingService, peripheralManager: PeripheralManager) { + init(loggingService: LoggingService) { self.log = loggingService - self.peripheralManager = peripheralManager - - super.init() - - if CBCentralManager.authorization == .allowedAlways { - setupCentralManager() - } - - updateState() - } - - private func setupCentralManager() { - guard centralManager == nil else { - return - } - - log.info("Initializing CBCentralManager") - - // Use CBCentralManagerFactory for consistency between normal and test targets - centralManager = CBCentralManagerFactory.instance(delegate: self, queue: queue, options: nil, forceMock: true) + + // `init` stays synchronous and dispatches actor setup via a fire-and-forget `Task` + // rather than awaiting it. The initial `setupCentralManager()` (if already + // `.allowedAlways`) and `updateState()` land on the actor's queue on the next + // run-loop turn, which is indistinguishable from prior behavior for callers that + // observe `state`/`currentState` after init. + // + // This does mean a caller that immediately awaits `startScanning()`/`stopScanning()` + // could in theory have that call's actor job ordered ahead of `initialize`'s, since + // Swift actors don't guarantee FIFO ordering across jobs enqueued from separate + // top-level Tasks. Both methods already guard on `centralManager == nil` and no-op + // with a log warning in that case, so the worst case is a missed scan start rather + // than a crash. Revisit if this race proves observable in practice — e.g. by making + // `init` `async` or exposing an explicit `await manager.ready()`. + Task { await BluetoothActor.shared.initialize(log: loggingService) } } - + // MARK: - State - - private let stateSubject = CurrentValueSubject(.unknown) - + /// Publisher for the real-time state of the underlying Core Bluetooth system. + /// Reads the `nonisolated(unsafe)` publisher on ``BluetoothActor``. TODO: removed in Step 3. var state: AnyPublisher { - stateSubject.eraseToAnyPublisher() + BluetoothActor.shared.statePublisher } - + /// Synchronous access to the current state of the underlying Core Bluetooth system. + /// Reads the `nonisolated(unsafe)` property on ``BluetoothActor``. TODO: removed in Step 3. var currentState: BluetoothState { - stateSubject.value + BluetoothActor.shared.currentBluetoothState } - private func updateState() { - switch CBCentralManager.authorization { - case .notDetermined: - stateSubject.send(.unauthorized(.notDetermined)) - - return - case .denied: - stateSubject.send(.unauthorized(.denied)) - - return - case .restricted: - stateSubject.send(.unauthorized(.restricted)) - - return - default: - break - } - - // Check for scanning before checking the centralManager state. If the centralManager is scanning, it has - // to be in a poweredOn state. So scanning is the "higher" state. - if centralManager?.isScanning == true { - stateSubject.send(.scanning) - - return - } - - switch centralManager?.state { - case .poweredOn: - stateSubject.send(.ready) - case .poweredOff: - stateSubject.send(.poweredOff) - case .resetting: - stateSubject.send(.resetting) - case .unsupported: - stateSubject.send(.unsupported) - case .unknown: - stateSubject.send(.unknown) - default: - stateSubject.send(.unknown) - } - } - - /// Requests authorization to use Bluetooth. This method will throw an error if the user has denied or restricted - /// Bluetooth access. + // MARK: - Authorization + + /// Requests authorization to use Bluetooth. This method will throw an error if the user + /// has denied or restricted Bluetooth access. /// - /// - Throws: An ``AuthorizationError`` error if the user has denied or restricted Bluetooth access. - func authorize() throws { - log.info("Authorizing bluetooth") - - switch CBCentralManager.authorization { - case .notDetermined: - setupCentralManager() - case .denied: - throw AuthorizationError.denied - case .restricted: - throw AuthorizationError.restricted - case .allowedAlways: - setupCentralManager() - @unknown default: - throw AuthorizationError.unknown - } + /// - Throws: An ``AuthorizationError`` error if the user has denied or restricted Bluetooth + /// access. + func authorize() async throws { + try await BluetoothActor.shared.authorize() } - + // MARK: - Scanning - - /// Starts (or restarts) scanning for peripherals. If scanning is already in progress, this method will replace the - /// current scan with a new scan. - /// - /// - Parameter services: An array of CBUUID objects that the app is interested in scanning for. If the value is - /// `nil`, the app scans for all peripherals. - /// - /// - Note: If Bluetooth is not authorized or powered on, this method will not start scanning. - func startScanning(services: [CBUUID]? = nil) { - guard let centralManager else { - log.warn(tags: [.category(.scanning)], "Attempted to start scan without a central manager") - - return - } - - guard centralManager.state == .poweredOn else { - log.warn(tags: [.category(.scanning)], "Attempted to start scan while central manager is not ready (poweredOn)") - - return - } - - centralManager.scanForPeripherals(withServices: services, options: nil) - - if centralManager.isScanning { - log.info(tags: [.category(.scanning)], "Scanning started with services: \(services ?? [])") - updateState() - } else { - log.warn(tags: [.category(.scanning)], "Failed to start scanning") - } - } - - /// Stops scanning for peripherals. - func stopScanning() { - guard let centralManager else { - log.warn(tags: [.category(.scanning)], "Attempted to stop scan without a central manager") - - return - } - - centralManager.stopScan() - - if !centralManager.isScanning { - log.info(tags: [.category(.scanning)], "Scanning stopped") - updateState() - } else { - log.warn(tags: [.category(.scanning)], "Failed to stop scanning") - } + + /// Publisher that emits peripheral discovery events during scanning. It is meant to be a + /// lightweight advertisements feed for cases where the integrating app needs to process + /// individual advertisements. + var peripheralDiscoveries: AnyPublisher { + BluetoothActor.shared.discoveryPublisher } - // MARK: - Peripheral Discovery - - private let discoverySubject = PassthroughSubject() - - /// Publisher that emits peripheral discovery events during scanning. - public var peripheralDiscoveries: AnyPublisher { - discoverySubject.eraseToAnyPublisher() + /// Publisher that emits the current list of discovered peripherals. + var discoveredPeripherals: AnyPublisher<[Peripheral], Never> { + BluetoothActor.shared.discoveredPeripheralsPublisher } - - // MARK: - CBCentralManagerDelegate - - func centralManagerDidUpdateState(_ central: CBCentralManager) { - log.debug("centralManagerDidUpdateState: \(central.state.rawValue)") - - // Invalidate or refresh peripherals based on the new state - switch central.state { - case .poweredOn: - peripheralManager.refreshPeripherals(using: central) - case .poweredOff: fallthrough - case .unknown: - // This state does not invalidate peripherals. - break - case .resetting: fallthrough - case .unsupported: fallthrough - case .unauthorized: - peripheralManager.invalidatePeripherals() - @unknown default: - log.error("Unknown CBCentralManager state encountered: \(central.state.rawValue)") - assertionFailure("Unknown CBCentralManager state encountered: \(central.state.rawValue)") - } - - updateState() + + /// Starts (or restarts) scanning for peripherals. If scanning is already in progress, + /// this method will replace the current scan with a new scan. + /// + /// - Parameter services: An array of CBUUID objects that the app is interested in scanning + /// for. If the value is `nil`, the app scans for all peripherals. + /// + /// - Note: If Bluetooth is not authorized or powered on, this method will not start + /// scanning. + func startScanning(services: sending [CBUUID]? = nil) async { + await BluetoothActor.shared.startScanning(services: services) } - - func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { - let peripheralDiscoveryEvent = PeripheralDiscoveryEvent(peripheral: peripheral, - advertisementData: advertisementData, - rssi: RSSI.intValue) - // Log before sending event so logs are time ordered correctly - // TODO: Implement verbose log level -// log.debug("Discovered peripheral: \(peripheralDiscoveryEvent.name ?? "Unknown") (RSSI: \(RSSI.stringValue))") - discoverySubject.send(peripheralDiscoveryEvent) - - peripheralManager.discoveredPeripheral(peripheral, advertisementData: advertisementData, rssi: RSSI.intValue) + + /// Stops scanning for peripherals. + func stopScanning() async { + await BluetoothActor.shared.stopScanning() } } diff --git a/Sources/ReliaBLE/Documentation.docc/Documentation.md b/Sources/ReliaBLE/Documentation.docc/Documentation.md index ca560ed..9741961 100644 --- a/Sources/ReliaBLE/Documentation.docc/Documentation.md +++ b/Sources/ReliaBLE/Documentation.docc/Documentation.md @@ -13,6 +13,10 @@ This is a temporary overview. - - +### Concurrency + +- ``BluetoothActor`` + ### Advanced Usage - diff --git a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md index 8eb332c..5587719 100644 --- a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md +++ b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md @@ -42,7 +42,7 @@ iOS requires permission from the user for BLE access. To set this up in your pro ReliaBLE does not automatically request authorization so that you are in control of when the user is prompted. To request Bluetooth permission from the user: ```swift do { - try bleManager.authorizeBluetooth() + try await bleManager.authorizeBluetooth() } catch AuthorizationError.denied { // Handle denied authorization } catch AuthorizationError.restricted { @@ -87,12 +87,11 @@ Example of starting and stopping a scan for all peripherals: ```swift // Check if Bluetooth is ready if bleManager.currentState == .ready { - bleManager.startScanning() + await bleManager.startScanning() // Stop scanning after 10 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 10) { - bleManager.stopScanning() - } + try? await Task.sleep(for: .seconds(10)) + await bleManager.stopScanning() } else { // Handle Bluetooth not ready (e.g., prompt user to enable Bluetooth) print("Bluetooth is not ready for scanning") @@ -107,12 +106,11 @@ import CoreBluetooth // Check if Bluetooth is ready if bleManager.currentState == .ready { let serviceUUIDs = [CBUUID(string: "180D"), CBUUID(string: "180F")] // Heart Rate and Battery services - bleManager.startScanning(services: serviceUUIDs) + await bleManager.startScanning(services: serviceUUIDs) // Stop scanning after 10 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 10) { - bleManager.stopScanning() - } + try? await Task.sleep(for: .seconds(10)) + await bleManager.stopScanning() } else { // Handle Bluetooth not ready (e.g., prompt user to enable Bluetooth) print("Bluetooth is not ready for scanning") diff --git a/Sources/ReliaBLE/Models/Peripheral.swift b/Sources/ReliaBLE/Models/Peripheral.swift index c0be7de..1507d02 100644 --- a/Sources/ReliaBLE/Models/Peripheral.swift +++ b/Sources/ReliaBLE/Models/Peripheral.swift @@ -102,10 +102,10 @@ public final class Peripheral: Identifiable, Hashable, @unchecked Sendable { return _lastSeen } - /// Creates a peripheral with a unique identifier and optional CoreBluetooth discovery data. - /// - /// Prefer observing ``ReliaBLEManager/discoveredPeripherals`` for devices discovered during scanning. - /// ReliaBLE updates only the ``Peripheral`` instances it manages internally and emits through that publisher. + /// Create a peripheral with a unique identifier and optional CoreBluetooth peripheral data. The integrating app + /// should use this initializer to create a `Peripheral` instance when it has a unique identifier for a peripheral + /// but has not yet discovered the peripheral with CoreBluetooth. ``BluetoothActor`` will update the instance + /// with CoreBluetooth data when the peripheral is discovered. /// - Parameters: /// - id: Unique identifier for the peripheral as set by the integrating app. /// - peripheral: Reference to the CoreBluetooth `CBPeripheral` object diff --git a/Sources/ReliaBLE/PeripheralManager.swift b/Sources/ReliaBLE/PeripheralManager.swift deleted file mode 100644 index dfc6c9a..0000000 --- a/Sources/ReliaBLE/PeripheralManager.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// PeripheralManager.swift -// ReliaBLE -// -// Created by Justin Bergen on 3/8/25. -// -// Copyright (c) 2025 Five3 Apps, LLC -// -// 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: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// 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. - -import Combine -import CoreBluetooth - -final class PeripheralManager: @unchecked Sendable { - private let log: LoggingService - - private var discoveredPeripherals = [Peripheral]() - private let discoveredPeripheralsSubject = PassthroughSubject<[Peripheral], Never>() - private let queue = DispatchQueue(label: "com.five3apps.relia-ble.peripheralmanager", qos: .userInitiated) - - public var discoveredPeripheralsPublisher: AnyPublisher<[Peripheral], Never> { - discoveredPeripheralsSubject.eraseToAnyPublisher() - } - - init(loggingService: LoggingService) { - self.log = loggingService - } - - func discoveredPeripheral(_ cbPeripheral: CBPeripheral, advertisementData: [String: Any]? = nil, rssi: Int? = nil) { - // TODO: FR-8.5: Unique Identifier from Manufacturing Data -- Connect to id once implmented - // TODO: If there's no identifier should we ignore it? - let identifier = cbPeripheral.name ?? advertisementData?[CBAdvertisementDataLocalNameKey] as? String ?? cbPeripheral.identifier.uuidString - - queue.sync { - // First check if the peripheral has already been discovered by app-provided identifier - if let existingPeripheral = discoveredPeripherals.first(where: { $0.id == identifier }) { - existingPeripheral.update(cbPeripheral: cbPeripheral, advertisementData: advertisementData, rssi: rssi) - discoveredPeripheralsSubject.send(discoveredPeripherals) - return - } - - // Next check if the peripheral has already been discovered by CBPeripheral identifier - if let existingPeripheral = discoveredPeripherals.first(where: { $0.peripheral?.identifier == cbPeripheral.identifier }) { - existingPeripheral.update(cbPeripheral: cbPeripheral, advertisementData: advertisementData, rssi: rssi) - discoveredPeripheralsSubject.send(discoveredPeripherals) - return - } - - let newPeripheral = Peripheral(id: identifier, peripheral: cbPeripheral, advertisementData: advertisementData, rssi: rssi) - log.debug(tags: [.category(.scanning), .peripheral(newPeripheral.id)], "Adding newly discovered peripheral") - discoveredPeripherals.append(newPeripheral) - discoveredPeripheralsSubject.send(discoveredPeripherals) - } - } - - func invalidatePeripherals() { - queue.sync { - for peripheral in discoveredPeripherals { - peripheral.invalidateCBPeripheral() - } - discoveredPeripheralsSubject.send(discoveredPeripherals) - log.debug("Invalidated all peripheral references") - } - } - - func refreshPeripherals(using centralManager: CBCentralManager) { - queue.sync { - let identifiers = discoveredPeripherals.compactMap { $0.peripheralIdentifier } - guard !identifiers.isEmpty else { - log.debug("No peripheral identifiers to refresh") - - return - } - - let retrievedPeripherals = centralManager.retrievePeripherals(withIdentifiers: identifiers) - for cbPeripheral in retrievedPeripherals { - if let peripheral = discoveredPeripherals.first(where: { $0.peripheralIdentifier == cbPeripheral.identifier }) { - peripheral.update(cbPeripheral: cbPeripheral) - } - } - discoveredPeripheralsSubject.send(discoveredPeripherals) - log.debug("Refreshed \(retrievedPeripherals.count) peripherals from CBCentralManager") - } - } -} diff --git a/Sources/ReliaBLE/ReliaBLEManager.swift b/Sources/ReliaBLE/ReliaBLEManager.swift index ed68dee..5a0717c 100644 --- a/Sources/ReliaBLE/ReliaBLEManager.swift +++ b/Sources/ReliaBLE/ReliaBLEManager.swift @@ -25,8 +25,8 @@ // SOFTWARE. import Combine -import Foundation import CoreBluetooth +import Foundation import Willow @@ -36,7 +36,6 @@ public class ReliaBLEManager { private let log: LoggingService private let bluetoothManager: BluetoothManager - private let peripheralManager: PeripheralManager /// Initializes the ReliaBLEManager with the provided configuration, or a default configuration if none is provided. /// @@ -51,8 +50,7 @@ public class ReliaBLEManager { loggingService.enabled = config.loggingEnabled log = loggingService - peripheralManager = PeripheralManager(loggingService: loggingService) - bluetoothManager = BluetoothManager(loggingService: loggingService, peripheralManager: peripheralManager) + bluetoothManager = BluetoothManager(loggingService: loggingService) } // MARK: - State @@ -71,8 +69,8 @@ public class ReliaBLEManager { /// Bluetooth access. /// /// - Throws: An ``AuthorizationError`` error if the user has denied or restricted Bluetooth access. - public func authorizeBluetooth() throws { - try bluetoothManager.authorize() + public func authorizeBluetooth() async throws { + try await bluetoothManager.authorize() } // MARK: - Scanning @@ -85,7 +83,7 @@ public class ReliaBLEManager { /// Publisher that emits the current list of discovered peripherals. public var discoveredPeripherals: AnyPublisher<[Peripheral], Never> { - peripheralManager.discoveredPeripheralsPublisher + bluetoothManager.discoveredPeripherals } /// Starts scanning for peripheral devices, optionally filtering by specific services. @@ -95,13 +93,13 @@ public class ReliaBLEManager { /// /// - Note: If Bluetooth is not authorized or powered on, this method will not start scanning. It is the caller's /// responsibility to ensure that Bluetooth is authorized and powered on before calling this method. - public func startScanning(services: [CBUUID]? = nil) { - bluetoothManager.startScanning(services: services) + public func startScanning(services: sending [CBUUID]? = nil) async { + await bluetoothManager.startScanning(services: services) } /// Stops scanning for peripheral devices. - public func stopScanning() { - bluetoothManager.stopScanning() + public func stopScanning() async { + await bluetoothManager.stopScanning() } func testFunction() -> String { diff --git a/docs/plans/bluetooth-actor-migration-2026-06-08.md b/docs/plans/bluetooth-actor-migration-2026-06-08.md new file mode 100644 index 0000000..a6d9cf2 --- /dev/null +++ b/docs/plans/bluetooth-actor-migration-2026-06-08.md @@ -0,0 +1,99 @@ +# BluetoothActor Migration: Plan (Step 1 of 5) +*Issue #13 · 2026-06-08* + +## Goal + +Introduce `@globalActor BluetoothActor` and `BluetoothDelegateShim` so all CoreBluetooth-touching state and callbacks are serialized by Swift actor isolation. Fixes the three unsynchronized concurrency domains currently sharing `centralManager`, `stateSubject`, and `discoverySubject` with zero synchronization (`BluetoothManager.swift:41,81,199`). `updateState()` reads `centralManager?.state` and `.isScanning` off-queue from the caller thread (`BluetoothManager.swift:108,113,170,195`), violating CoreBluetooth's queue-affinity contract. See `docs/investigations/swift6-concurrency-audit-2026-05-13.md §1, §A, §D`. + +This is **Step 1 of 5**. The public `AnyPublisher` surface and the `Peripheral` class are untouched; those are Steps 3 (#12) and 2 (#17). + +## Background + +**Three domains, no synchronization.** +- Domain 1 (caller thread): `init`, `authorize`, `startScanning`, `stopScanning`, `updateState` +- Domain 2 (`BluetoothManager.queue` dispatch): all `CBCentralManagerDelegate` callbacks +- Domain 3 (Combine subscriber threads): downstream of `subject.send(...)` + +**Mock target.** `CoreBluetoothMockAliases.swift` aliases `CBCentralManagerDelegate` → `CBMCentralManagerDelegate`. The new shim conforming to `CBCentralManagerDelegate` picks up the mock type automatically in `ReliaBLEMock` — no alias changes needed. `CBCentralManagerFactory.swift` is excluded from `ReliaBLEMock` via `Package.swift:33`. + +**Step handoffs (do not implement here):** +- Step 2 (#17): `Peripheral` → `Sendable` value struct; `CBPeripheral` registry fully actor-owned +- Step 3 (#12): Replace `AnyPublisher` with `AsyncStream`; remove all `@unchecked Sendable`/`nonisolated(unsafe)` Combine workarounds from this step +- Step 4 (#18): `ReliaBLEManager` → `nonisolated Sendable`; collapse `BluetoothManager` into the actor +- Step 5 (#19): `-strict-concurrency=complete` flag flip + logging polish + DocC + +## Approach + +### `BluetoothActor` — `@globalActor` singleton + +`BluetoothActor` is a **process-wide** `@globalActor` (`static let shared = BluetoothActor()`). Two `ReliaBLEManager` instances share the same actor (same isolation domain). This matches the issue spec and is acceptable because a single BLE central manager per process is already a CoreBluetooth constraint. + +All mutable BLE state moves here: `centralManager: CBCentralManager?`, the three Combine subjects, and the `discoveredPeripherals: [Peripheral]` array (currently on `PeripheralManager`). All mutations happen from actor-isolated methods only. + +### `BluetoothDelegateShim` — nonisolated delegate bridge + +`nonisolated final class: NSObject, CBCentralManagerDelegate`. No stored state. Each delegate callback is `nonisolated` and hops to the actor via `Task { @BluetoothActor in BluetoothActor.shared.handleXxx(...) }`. No weak/unowned capture needed — the shim simply references the process-lifetime `BluetoothActor.shared` singleton. + +### Combine bridge (transitional) + +Subjects are regular **actor-isolated** `let` properties. Publishers are extracted once in actor `init` and stored as `nonisolated(unsafe) let` properties: + +```swift +private let stateSubject = CurrentValueSubject(.unknown) +nonisolated(unsafe) let statePublisher: AnyPublisher +// in init: statePublisher = stateSubject.eraseToAnyPublisher() +// TODO: removed in Step 3 +``` + +`subject.send(...)` is only ever called from actor-isolated context (serial executor prevents concurrent writes). Reading the publisher reference via `nonisolated(unsafe)` is safe. `BluetoothManager`'s sync `AnyPublisher` computed properties consume these directly. Every workaround is tagged `// TODO: removed in Step 3`. + +`Peripheral` is already `@unchecked Sendable` (`Peripheral.swift:41`), so `[Peripheral]` satisfies `Sendable`. The `discoveredPeripheralsSubject` emission compiles under the same pattern. + +### `currentState` sync access + +The actor exposes a single `nonisolated(unsafe) var currentBluetoothState: BluetoothState = .unknown`. Only the actor-isolated `broadcastState(_:)` method writes it (serial executor prevents concurrent writes). `BluetoothManager.currentState` reads it directly. Tagged `// TODO: removed in Step 3`. + +### `init` stays synchronous + +`BluetoothManager.init` and `ReliaBLEManager.init` must remain synchronous. The initial `setupCentralManager()` (if auth is already `.allowedAlways`) and `updateState()` calls dispatch via `Task { @BluetoothActor in … }` rather than awaiting. Consumers see the initial state on the next run-loop turn — indistinguishable from current behavior. + +### Public API: `authorizeBluetooth` / `startScanning` / `stopScanning` become `async` + +By decision: Step 1 owns the public-API async break. `ReliaBLEManager.authorizeBluetooth()`, `startScanning()`, `stopScanning()` become `async throws`/`async`. Pre-1.0, no external consumers; breaking is acceptable. Step 4 removes `BluetoothManager` as an internal indirection but does not re-break the API. + +### `PeripheralManager` is deleted + +`discoveredPeripheral(_:advertisementData:rssi:)`, `invalidatePeripherals()`, and the discovery dedup logic move directly onto `BluetoothActor`. `refreshPeripherals(using:)` (`PeripheralManager.swift:81`) no longer needs its `CBCentralManager` parameter — actor-isolated code accesses `self.centralManager` directly. `PeripheralManager.swift` is deleted. Step 4 inherits no collapse work from this file. + +### Preserved invariants + +- `forceMock: true` literal at `CBCentralManagerFactory.instance(...)` — unchanged, just moves into the actor's `setupCentralManager()`. +- Lazy-init of `CBCentralManager` — `setupCentralManager()` retains its `guard centralManager == nil` check and is still called only when `.allowedAlways` or `authorize()` is called. +- Three-target SPM trick — `Package.swift` untouched; mock aliases cover the shim's delegate conformance automatically. + +## Work Items + +1. **Create `Sources/ReliaBLE/BluetoothActor.swift`** — declare `@globalActor public actor BluetoothActor { public static let shared = BluetoothActor() }` with all actor-isolated state (`centralManager`, subjects, `discoveredPeripherals`), `nonisolated(unsafe)` publisher + `currentBluetoothState` properties, and `BluetoothDelegateShim`. + +2. **Port actor-isolated methods** — `setupCentralManager`, `authorize`, `startScanning`, `stopScanning`, `updateState`/`broadcastState`, `centralManagerDidUpdateState`, `centralManager(_:didDiscover:...)`, plus the three inlined `PeripheralManager` methods (discovery dedup, invalidate, refresh-without-param). + +3. **Wire the shim** — shim's `@Sendable` closures hop each callback to `BluetoothActor.shared`. Factory call in `setupCentralManager` passes the shim as delegate. Shim stored as actor property to keep it alive. + +4. **Refactor `BluetoothManager`** — remove `NSObject`/`CBCentralManagerDelegate` conformance, `queue: DispatchQueue`. Methods `authorize()`, `startScanning()`, `stopScanning()` become `async` forwarders. Sync computed properties (`state`, `currentState`, `peripheralDiscoveries`) read from actor's `nonisolated(unsafe)` properties. + +5. **Delete `Sources/ReliaBLE/PeripheralManager.swift`**. + +6. **Update `ReliaBLEManager`** — `authorizeBluetooth()`, `startScanning()`, `stopScanning()` become `async throws`/`async`. Update DocC on these methods. Publisher properties unchanged. + +7. **Update Demo app** — `Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift:101,105,109` calls `authorizeBluetooth()`, `startScanning()`, and `stopScanning()` synchronously. Wrap each in `Task { await … }` (or `Task { try? await … }` for the throwing one). No other Demo files call these methods. Demo conventions are looser (see `Demo/AGENTS.md`) — fire-and-forget `Task {}` is appropriate here. + +8. **Build and test** — `swift build` clean; `swift test` passes. Grep that no `centralManager.state` / `centralManager.isScanning` read occurs outside actor isolation. + +9. **DocC** — Add `BluetoothActor` entry in `Documentation.docc/`. Update `GettingStarted.md` for the async public methods. + +## References + +- Audit: `docs/investigations/swift6-concurrency-audit-2026-05-13.md` — §1 (Cluster 1), §A, §D +- Issue #13: https://github.com/Five3Apps/ReliaBLE/issues/13 +- Parent: https://github.com/Five3Apps/ReliaBLE/issues/10 +- WWDC24 session 10169 — Migrate your app to Swift 6 From 83e5bcaa274b0b4d80a3dfac867f389f07bdf94c Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 13 Jun 2026 16:04:48 -0600 Subject: [PATCH 11/27] Apply Copilot PR review fixes from #23 evaluation - Restore stored peripheralIdentifier: seed in init, refresh in update, don't clear in invalidateCBPeripheral. Fixes refreshPeripherals after invalidation (Rec 1, Copilot comments #1/#2/#3). - Restore dedicated serial DispatchQueue for CBCentralManager instead of queue: nil, keeping callbacks off the main thread (Rec 2, comment #9). - Adopt plan-prescribed @BluetoothActor global-actor annotation in BluetoothDelegateShim Task hops (Rec 3, comment #6). - Remove redundant nonisolated on hash(into:) and fix CBPeriphal typo in == doc comment (Rec 4, comments #4 partial / #5). Co-Authored-By: Claude Opus 4.8 --- .../ReliaBLE Demo.xcodeproj/project.pbxproj | 4 ++-- Sources/ReliaBLE/BluetoothActor.swift | 8 +++++--- Sources/ReliaBLE/Models/Peripheral.swift | 14 +++++++------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj b/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj index d4b6eb9..1d5a954 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj @@ -442,7 +442,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 6.0; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; XROS_DEPLOYMENT_TARGET = 2.1; }; @@ -486,7 +486,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 6.0; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; XROS_DEPLOYMENT_TARGET = 2.1; }; diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index 3295289..d926980 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -61,6 +61,8 @@ public actor BluetoothActor { // MARK: - Actor-Isolated State + private let centralManagerQueue = DispatchQueue(label: "com.five3apps.relia-ble.bluetoothmanager", qos: .userInitiated) + var centralManager: CBCentralManager? private var delegateShim: BluetoothDelegateShim? @@ -132,7 +134,7 @@ public actor BluetoothActor { delegateShim = shim // Use CBCentralManagerFactory for consistency between normal and test targets. // `forceMock: true` is load-bearing for the ReliaBLEMock test target — do not remove. - centralManager = CBCentralManagerFactory.instance(delegate: shim, queue: nil, options: nil, forceMock: true) + centralManager = CBCentralManagerFactory.instance(delegate: shim, queue: centralManagerQueue, options: nil, forceMock: true) } // MARK: - Authorization @@ -342,7 +344,7 @@ public actor BluetoothActor { final class BluetoothDelegateShim: NSObject, CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { - Task { await BluetoothActor.shared.handleCentralManagerStateUpdate() } + Task { @BluetoothActor in await BluetoothActor.shared.handleCentralManagerStateUpdate() } } func centralManager( @@ -356,6 +358,6 @@ final class BluetoothDelegateShim: NSObject, CBCentralManagerDelegate { let p = SendableWrapper(value: peripheral) let ad = SendableWrapper(value: advertisementData) let rssi = RSSI.intValue - Task { await BluetoothActor.shared.handlePeripheralDiscovered(p.value, advertisementData: ad.value, rssi: rssi) } + Task { @BluetoothActor in await BluetoothActor.shared.handlePeripheralDiscovered(p.value, advertisementData: ad.value, rssi: rssi) } } } diff --git a/Sources/ReliaBLE/Models/Peripheral.swift b/Sources/ReliaBLE/Models/Peripheral.swift index 1507d02..ff5ae8a 100644 --- a/Sources/ReliaBLE/Models/Peripheral.swift +++ b/Sources/ReliaBLE/Models/Peripheral.swift @@ -42,12 +42,10 @@ public final class Peripheral: Identifiable, Hashable, @unchecked Sendable { public let id: String /// The CoreBluetooth peripheral identifier, used to retrieve the peripheral after invalidation. + private var _peripheralIdentifier: UUID? var peripheralIdentifier: UUID? { - // No lock needed here since this is a computed property that accesses `peripheral` - // which already handles its own synchronization. - - // If we have a valid CBPeripheral, return its identifier. - return peripheral?.identifier + mutationLock.lock(); defer { mutationLock.unlock() } + return _peripheralIdentifier } private var _peripheral: CBPeripheral? @@ -117,6 +115,7 @@ public final class Peripheral: Identifiable, Hashable, @unchecked Sendable { mutationLock.name = "Peripheral-\(id)-MutationLock" _peripheral = peripheral + _peripheralIdentifier = peripheral?.identifier _rssi = rssi _advertisementData = advertisementData @@ -131,6 +130,7 @@ public final class Peripheral: Identifiable, Hashable, @unchecked Sendable { mutationLock.lock(); defer { mutationLock.unlock() } _peripheral = cbPeripheral + _peripheralIdentifier = cbPeripheral.identifier _advertisementData = advertisementData _rssi = rssi @@ -148,13 +148,13 @@ public final class Peripheral: Identifiable, Hashable, @unchecked Sendable { _peripheral = nil } - nonisolated public func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(id) } public static func == (lhs: Peripheral, rhs: Peripheral) -> Bool { // No need to compare `CBPeripheral` objects or identifiers, since the `id` is unique and matching between - // `Peripheral` and `CBPeriphal` instances are handled internally. + // `Peripheral` and `CBPeripheral` instances are handled internally. return lhs.id == rhs.id } } From e48ca32e0c38ddf70d52140ba300ec0b21b4458b Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Thu, 18 Jun 2026 15:30:05 -0600 Subject: [PATCH 12/27] Make Peripheral a Sendable value type (Step 2 of 5 for #10) Convert `Peripheral` from a reference-wrapping type into an immutable, `Sendable` value snapshot that carries no `CBPeripheral` reference. The live `CBPeripheral` is now owned exclusively by `BluetoothActor` in an `id`-keyed registry that never escapes the actor; operations forward the snapshot's `id` and the actor resolves the live reference, throwing `PeripheralError.notFound` when a snapshot has gone stale. - Add `AdvertisementData` to expose typed advertisement fields as values - Add `PeripheralError` for stale/unresolvable snapshot lookups - Add `CBUUID+Sendable` retroactive conformance - Let apps pre-register a known peripheral via `Peripheral(id:)` - Update `BluetoothActor` discovery to snapshot values and retain the non-`Sendable` CoreBluetooth payload only inside the actor - Update DocC and tests for the new value-type API --- Sources/ReliaBLE/BluetoothActor.swift | 121 +++++++++--- Sources/ReliaBLE/BluetoothManager.swift | 10 + Sources/ReliaBLE/CBUUID+Sendable.swift | 37 ++++ .../Documentation.docc/Documentation.md | 7 + .../Documentation.docc/GettingStarted.md | 37 ++++ .../ReliaBLE/Models/AdvertisementData.swift | 80 ++++++++ .../Events/PeripheralDiscoveryEvent.swift | 23 +-- Sources/ReliaBLE/Models/Peripheral.swift | 187 +++++++----------- Sources/ReliaBLE/Models/PeripheralError.swift | 38 ++++ Sources/ReliaBLE/ReliaBLEManager.swift | 17 ++ .../ReliaBLETests/ReliaBLEManagerTests.swift | 19 ++ .../peripheral-sendable-struct-2026-06-13.md | 171 ++++++++++++++++ ...endable-struct-plan-critique-2026-06-13.md | 33 ++++ 13 files changed, 619 insertions(+), 161 deletions(-) create mode 100644 Sources/ReliaBLE/CBUUID+Sendable.swift create mode 100644 Sources/ReliaBLE/Models/AdvertisementData.swift create mode 100644 Sources/ReliaBLE/Models/PeripheralError.swift create mode 100644 docs/plans/peripheral-sendable-struct-2026-06-13.md create mode 100644 docs/reviews/peripheral-sendable-struct-plan-critique-2026-06-13.md diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index d926980..cfb1167 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -36,7 +36,9 @@ import Foundation /// isolation domains. This wrapper is used only for the transitional hop between /// CoreBluetooth's delegate queue and ``BluetoothActor``. /// -/// **TODO: removed in Step 2** when `Peripheral` becomes a `Sendable` value type. +/// Still required: the raw `CBPeripheral` and `[String: Any]` advertisement dictionary delivered by the delegate +/// are non-`Sendable` and must cross into the actor. ``Peripheral`` itself is now a `Sendable` value type, but the +/// CoreBluetooth payload it is built from is not extracted until inside the actor. private struct SendableWrapper: @unchecked Sendable { let value: T } @@ -68,8 +70,15 @@ public actor BluetoothActor { var log: LoggingService? + /// Value snapshots of all discovered peripherals, keyed implicitly by ``Peripheral/id``. var discoveredPeripherals: [Peripheral] = [] + /// Live `CBPeripheral` references keyed by ``Peripheral/id``. + /// + /// This mutable, non-`Sendable` reference map never escapes the actor. ``Peripheral`` snapshots carry only an + /// `id`; operations that need the live peripheral look it up here. + private var cbPeripherals: [String: CBPeripheral] = [:] + // Combine subjects — written only from actor-isolated context. let stateSubject = CurrentValueSubject(.unknown) let discoverySubject = PassthroughSubject() @@ -267,48 +276,72 @@ public actor BluetoothActor { advertisementData: [String: Any], rssi: Int ) { - // Inlined rather than calling private helpers so that the non-Sendable - // CBPeripheral reference never appears at an actor method call site. - // The Swift 6 region-based isolation checker reports a false positive - // ("please file a bug") when a non-Sendable value is passed to an - // actor-isolated method, even from within the same actor. - // TODO: Step 2 — refactor once Peripheral is a Sendable value type. + // Extract the untyped advertisement dictionary into a typed, Sendable snapshot exactly once. The raw + // `[String: Any]` does not leave this actor; the same `AdvertisementData` feeds both the discovery event + // and the stored `Peripheral` snapshot. + let advertisement = AdvertisementData(rawAdvertisementData: advertisementData) // Emit lightweight discovery feed. // TODO: Implement verbose log level discoverySubject.send( - PeripheralDiscoveryEvent(peripheral: cbPeripheral, advertisementData: advertisementData, rssi: rssi) + PeripheralDiscoveryEvent(cbPeripheral: cbPeripheral, advertisement: advertisement, rssi: rssi) ) // TODO: FR-8.5: Unique Identifier from Manufacturing Data — connect to id once implemented let identifier = cbPeripheral.name - ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String + ?? advertisement.localName ?? cbPeripheral.identifier.uuidString - // Check if already discovered by app-provided identifier - if let existing = discoveredPeripherals.first(where: { $0.id == identifier }) { - existing.update(cbPeripheral: cbPeripheral, advertisementData: advertisementData, rssi: rssi) - discoveredPeripheralsSubject.send(discoveredPeripherals) - return - } - - // Check if already discovered by CBPeripheral identifier - if let existing = discoveredPeripherals.first(where: { $0.peripheralIdentifier == cbPeripheral.identifier }) { - existing.update(cbPeripheral: cbPeripheral, advertisementData: advertisementData, rssi: rssi) - discoveredPeripheralsSubject.send(discoveredPeripherals) - return + let cbIdentifier = cbPeripheral.identifier + let name = cbPeripheral.name ?? advertisement.localName + let now = Date() + + // Resolve the id to store under. Prefer an existing entry matching the app-facing `identifier`; otherwise + // fall back to an existing entry for the same `CBPeripheral` (whose resolved `id` may differ if the name has + // since changed), preserving that entry's original `id`. Otherwise this is a brand-new peripheral. + let resolvedId: String + if let idx = discoveredPeripherals.firstIndex(where: { $0.id == identifier }) { + resolvedId = identifier + discoveredPeripherals[idx] = Peripheral( + id: resolvedId, + cbIdentifier: cbIdentifier, + name: name, + rssi: rssi, + lastSeen: now, + advertisement: advertisement + ) + } else if let idx = discoveredPeripherals.firstIndex(where: { $0.cbIdentifier == cbIdentifier }) { + resolvedId = discoveredPeripherals[idx].id + discoveredPeripherals[idx] = Peripheral( + id: resolvedId, + cbIdentifier: cbIdentifier, + name: name, + rssi: rssi, + lastSeen: now, + advertisement: advertisement + ) + } else { + resolvedId = identifier + let new = Peripheral( + id: resolvedId, + cbIdentifier: cbIdentifier, + name: name, + rssi: rssi, + lastSeen: now, + advertisement: advertisement + ) + log?.debug(tags: [.category(.scanning), .peripheral(new.id)], "Adding newly discovered peripheral") + discoveredPeripherals.append(new) } - let new = Peripheral(id: identifier, peripheral: cbPeripheral, advertisementData: advertisementData, rssi: rssi) - log?.debug(tags: [.category(.scanning), .peripheral(new.id)], "Adding newly discovered peripheral") - discoveredPeripherals.append(new) + // Stash the live reference under the resolved id. Never escapes the actor. + cbPeripherals[resolvedId] = cbPeripheral discoveredPeripheralsSubject.send(discoveredPeripherals) } private func invalidatePeripherals() { - for peripheral in discoveredPeripherals { - peripheral.invalidateCBPeripheral() - } + // The value snapshots hold no CoreBluetooth reference to clear; drop the live registry instead. + cbPeripherals.removeAll() discoveredPeripheralsSubject.send(discoveredPeripherals) log?.debug("Invalidated all peripheral references") } @@ -316,7 +349,7 @@ public actor BluetoothActor { private func refreshPeripherals() { guard let centralManager else { return } - let identifiers = discoveredPeripherals.compactMap { $0.peripheralIdentifier } + let identifiers = discoveredPeripherals.compactMap { $0.cbIdentifier } guard !identifiers.isEmpty else { log?.debug("No peripheral identifiers to refresh") return @@ -324,13 +357,37 @@ public actor BluetoothActor { let retrieved = centralManager.retrievePeripherals(withIdentifiers: identifiers) for cbPeripheral in retrieved { - if let p = discoveredPeripherals.first(where: { $0.peripheralIdentifier == cbPeripheral.identifier }) { - p.update(cbPeripheral: cbPeripheral) + if let p = discoveredPeripherals.first(where: { $0.cbIdentifier == cbPeripheral.identifier }) { + cbPeripherals[p.id] = cbPeripheral } } discoveredPeripheralsSubject.send(discoveredPeripherals) log?.debug("Refreshed \(retrieved.count) peripherals from CBCentralManager") } + + // MARK: - Connection + + /// Initiates a connection to the live peripheral backing the given snapshot `id`. + /// + /// - Parameter id: The ``Peripheral/id`` of a previously discovered peripheral. + /// - Throws: ``PeripheralError/notFound`` if no live `CBPeripheral` is registered for `id` (a stale snapshot). + /// + /// - Note: This currently only fires the connection request. The full connection lifecycle + /// (didConnect/didDisconnect handling and a connection-state surface) is deferred to a later release. + func connect(id: String) throws { + guard let cbPeripheral = cbPeripherals[id] else { + throw PeripheralError.notFound + } + + guard let centralManager else { + // Mirrors the start/stopScanning convention: no central manager means there is nothing to act on. + // In practice unreachable — the registry is only populated while a central manager exists. + log?.warn(tags: [.peripheral(id)], "Attempted to connect without a central manager") + return + } + + centralManager.connect(cbPeripheral, options: nil) + } } // MARK: - BluetoothDelegateShim @@ -353,8 +410,8 @@ final class BluetoothDelegateShim: NSObject, CBCentralManagerDelegate { advertisementData: [String: Any], rssi RSSI: NSNumber ) { - // Wrap non-Sendable values for the actor isolation hop. - // TODO: Step 2 — remove SendableWrapper when Peripheral becomes a Sendable value type. + // Wrap the non-Sendable CBPeripheral and advertisement dictionary for the actor isolation hop. + // They are extracted into Sendable types (Peripheral / AdvertisementData) inside the actor. let p = SendableWrapper(value: peripheral) let ad = SendableWrapper(value: advertisementData) let rssi = RSSI.intValue diff --git a/Sources/ReliaBLE/BluetoothManager.swift b/Sources/ReliaBLE/BluetoothManager.swift index b6fd66a..e8f766c 100644 --- a/Sources/ReliaBLE/BluetoothManager.swift +++ b/Sources/ReliaBLE/BluetoothManager.swift @@ -120,6 +120,16 @@ class BluetoothManager { func stopScanning() async { await BluetoothActor.shared.stopScanning() } + + // MARK: - Connection + + /// Initiates a connection to the given peripheral. + /// + /// - Parameter peripheral: A peripheral previously delivered via ``discoveredPeripherals``. + /// - Throws: ``PeripheralError/notFound`` if the peripheral is no longer known to the library. + func connect(to peripheral: Peripheral) async throws { + try await BluetoothActor.shared.connect(id: peripheral.id) + } } // MARK: - Public Types diff --git a/Sources/ReliaBLE/CBUUID+Sendable.swift b/Sources/ReliaBLE/CBUUID+Sendable.swift new file mode 100644 index 0000000..d00d805 --- /dev/null +++ b/Sources/ReliaBLE/CBUUID+Sendable.swift @@ -0,0 +1,37 @@ +// +// CBUUID+Sendable.swift +// ReliaBLE +// +// Created by Justin Bergen on 6/13/26. +// +// Copyright (c) 2026 Five3 Apps, LLC +// +// 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: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// 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. + +import CoreBluetooth + +// `CBUUID` is effectively immutable after initialization — it wraps a fixed 16-, 32-, or 128-bit +// Bluetooth UUID and exposes no mutating API. Marking it `@unchecked Sendable` lets value types that +// store `CBUUID` (notably ``AdvertisementData``) be `Sendable` and cross the ``BluetoothActor`` boundary. +// +// This declaration lives in the shared source tree, so in the `ReliaBLEMock` target it applies to +// `CBMUUID` via the `CBUUID = CBMUUID` typealias. If a future version of CoreBluetoothMock declares +// `CBMUUID: Sendable` upstream, this becomes a redundant-conformance error in that target only; guard +// it per target at that point. +extension CBUUID: @retroactive @unchecked Sendable {} diff --git a/Sources/ReliaBLE/Documentation.docc/Documentation.md b/Sources/ReliaBLE/Documentation.docc/Documentation.md index 9741961..87ccd63 100644 --- a/Sources/ReliaBLE/Documentation.docc/Documentation.md +++ b/Sources/ReliaBLE/Documentation.docc/Documentation.md @@ -13,6 +13,13 @@ This is a temporary overview. - - +### Peripherals + +- ``Peripheral`` +- ``AdvertisementData`` +- ``PeripheralDiscoveryEvent`` +- ``PeripheralError`` + ### Concurrency - ``BluetoothActor`` diff --git a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md index 5587719..836e772 100644 --- a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md +++ b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md @@ -118,3 +118,40 @@ if bleManager.currentState == .ready { ``` You can monitor the ``ReliaBLEManager/state`` publisher (as shown in the Authorizing Bluetooth section) to ensure Bluetooth is in the `.ready` state before calling `startScanning()`. Scanning will continue until you explicitly call `stopScanning()` or if Bluetooth becomes unavailable. + +## Observing Discovered Peripherals + +While scanning, ReliaBLE surfaces results two ways: + +- ``ReliaBLEManager/peripheralDiscoveries`` emits a lightweight ``PeripheralDiscoveryEvent`` for every advertisement received — useful when you need to process individual advertisement packets. +- ``ReliaBLEManager/discoveredPeripherals`` emits the current de-duplicated list of ``Peripheral`` values each time it changes. + +A ``Peripheral`` is an immutable, `Sendable` value snapshot: it carries the peripheral's ``Peripheral/id``, ``Peripheral/name``, ``Peripheral/rssi``, ``Peripheral/lastSeen``, and a strongly-typed ``AdvertisementData`` rather than a raw `[String: Any]` dictionary. Because it is a value type, it is safe to hand directly to your UI. + +```swift +bleManager.discoveredPeripherals + .receive(on: DispatchQueue.main) + .sink { peripherals in + for peripheral in peripherals { + print(peripheral.name ?? peripheral.id, peripheral.advertisement?.serviceUUIDs ?? []) + } + } + .store(in: &cancellables) +``` + +If your app already knows a peripheral's identity ahead of time — for example, a wearable bound to the user's account — you can construct a ``Peripheral`` directly with ``Peripheral/init(id:)``. Such a snapshot has no ``Peripheral/advertisement`` until ReliaBLE matches it against the corresponding device during discovery. + +## Connecting to a Peripheral + +Pass a discovered ``Peripheral`` to ``ReliaBLEManager/connect(to:)``. The snapshot carries only a stable identifier; ReliaBLE looks up the live CoreBluetooth peripheral it owns internally and initiates the connection. + +```swift +do { + try await bleManager.connect(to: peripheral) +} catch PeripheralError.notFound { + // The snapshot is stale — its underlying peripheral reference was invalidated + // (for example, after a Bluetooth reset). Re-scan to rediscover it. +} +``` + +- Note: `connect(to:)` currently initiates the connection request only. The full connection lifecycle (connection-state updates and disconnection handling) will arrive in a later release. diff --git a/Sources/ReliaBLE/Models/AdvertisementData.swift b/Sources/ReliaBLE/Models/AdvertisementData.swift new file mode 100644 index 0000000..66eed16 --- /dev/null +++ b/Sources/ReliaBLE/Models/AdvertisementData.swift @@ -0,0 +1,80 @@ +// +// AdvertisementData.swift +// ReliaBLE +// +// Created by Justin Bergen on 6/13/26. +// +// Copyright (c) 2026 Five3 Apps, LLC +// +// 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: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// 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. + +import CoreBluetooth +import Foundation + +/// A strongly-typed, `Sendable` snapshot of a peripheral's advertisement data. +/// +/// CoreBluetooth surfaces advertisement data as a loosely-typed `[String: Any]` dictionary keyed by +/// `CBAdvertisementData*` constants. ``AdvertisementData`` extracts those values once, at discovery time and inside +/// ``BluetoothActor``, so the untyped dictionary never crosses the actor boundary into the public surface. +/// +/// - Note: This is a typed-only representation. Vendor-specific or otherwise non-standard advertisement keys are not +/// currently surfaced; a raw escape hatch may be added in a future release if needed. +public struct AdvertisementData: Sendable, Hashable { + /// The local name of the peripheral, from `CBAdvertisementDataLocalNameKey`. + public let localName: String? + + /// The advertised service UUIDs, from `CBAdvertisementDataServiceUUIDsKey`. + public let serviceUUIDs: [CBUUID] + + /// The manufacturer-specific data, from `CBAdvertisementDataManufacturerDataKey`. + public let manufacturerData: Data? + + /// The transmit power level, from `CBAdvertisementDataTxPowerLevelKey`. + public let txPowerLevel: Int? + + /// Whether the advertising event is connectable, from `CBAdvertisementDataIsConnectable`. + public let isConnectable: Bool? + + /// Service-specific advertisement data, keyed by service UUID, from `CBAdvertisementDataServiceDataKey`. + public let serviceData: [CBUUID: Data] + + /// Service UUIDs found in the advertisement's overflow area, from `CBAdvertisementDataOverflowServiceUUIDsKey`. + public let overflowServiceUUIDs: [CBUUID] + + /// Solicited service UUIDs, from `CBAdvertisementDataSolicitedServiceUUIDsKey`. + public let solicitedServiceUUIDs: [CBUUID] + + /// Extracts a typed snapshot from CoreBluetooth's raw advertisement dictionary. + /// + /// This initializer is intentionally internal: the untyped `[String: Any]` should be extracted exactly once, + /// inside ``BluetoothActor``, and never exposed to consumers. + /// + /// - Parameter rawAdvertisementData: The advertisement dictionary delivered by + /// `centralManager(_:didDiscover:advertisementData:rssi:)`. + init(rawAdvertisementData: [String: Any]) { + localName = rawAdvertisementData[CBAdvertisementDataLocalNameKey] as? String + serviceUUIDs = rawAdvertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? [] + manufacturerData = rawAdvertisementData[CBAdvertisementDataManufacturerDataKey] as? Data + txPowerLevel = (rawAdvertisementData[CBAdvertisementDataTxPowerLevelKey] as? NSNumber)?.intValue + isConnectable = (rawAdvertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue + serviceData = rawAdvertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] ?? [:] + overflowServiceUUIDs = rawAdvertisementData[CBAdvertisementDataOverflowServiceUUIDsKey] as? [CBUUID] ?? [] + solicitedServiceUUIDs = rawAdvertisementData[CBAdvertisementDataSolicitedServiceUUIDsKey] as? [CBUUID] ?? [] + } +} diff --git a/Sources/ReliaBLE/Models/Events/PeripheralDiscoveryEvent.swift b/Sources/ReliaBLE/Models/Events/PeripheralDiscoveryEvent.swift index 003ed1c..a09b3fe 100644 --- a/Sources/ReliaBLE/Models/Events/PeripheralDiscoveryEvent.swift +++ b/Sources/ReliaBLE/Models/Events/PeripheralDiscoveryEvent.swift @@ -27,31 +27,26 @@ import Foundation import CoreBluetooth -/// A representation of a discovered Bluetooth peripheral with metadata. -public struct PeripheralDiscoveryEvent: Identifiable, Hashable { +/// A lightweight, `Sendable` event emitted for each advertisement received while scanning. +public struct PeripheralDiscoveryEvent: Identifiable, Hashable, Sendable { /// Unique identifier for the peripheral as set by CoreBluetooth public let id: UUID /// The name advertised by the peripheral, if available public let name: String? - /// Advertised service UUIDs - public var serviceUUIDs: [CBUUID]? { - advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] - } - /// Signal strength indicator (RSSI) public let rssi: Int - /// Complete advertisement data dictionary from the most recent discovery - public let advertisementData: [String: Any] + /// The typed advertisement data from this discovery. + public let advertisement: AdvertisementData - /// Create a discovered peripheral from CoreBluetooth information - init(peripheral: CBPeripheral, advertisementData: [String: Any], rssi: Int) { - self.id = peripheral.identifier - self.name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String + /// Create a discovered peripheral event from CoreBluetooth information. + init(cbPeripheral: CBPeripheral, advertisement: AdvertisementData, rssi: Int) { + self.id = cbPeripheral.identifier + self.name = cbPeripheral.name ?? advertisement.localName self.rssi = rssi - self.advertisementData = advertisementData + self.advertisement = advertisement } public func hash(into hasher: inout Hasher) { diff --git a/Sources/ReliaBLE/Models/Peripheral.swift b/Sources/ReliaBLE/Models/Peripheral.swift index ff5ae8a..706750f 100644 --- a/Sources/ReliaBLE/Models/Peripheral.swift +++ b/Sources/ReliaBLE/Models/Peripheral.swift @@ -27,134 +27,91 @@ import CoreBluetooth import Foundation -/// A representation of a discovered Bluetooth peripheral with metadata. +/// An immutable, `Sendable` value snapshot of a Bluetooth peripheral and its metadata. /// -/// This peripheral is a wrapper around the CoreBluetooth `CBPeripheral` object and provides additional metadata -/// and functionality. +/// A `Peripheral` carries no reference to the underlying CoreBluetooth `CBPeripheral`. The live `CBPeripheral` is +/// owned exclusively by ``BluetoothActor`` in an `id`-keyed registry that never escapes the actor. Operations that +/// need the live peripheral (such as ``ReliaBLEManager/connect(to:)``) forward the snapshot's ``id``; the actor looks +/// up the live reference and throws ``PeripheralError/notFound`` if the snapshot has since gone stale. /// -/// A `Peripheral` can exist without a `CBPeripheral` but all `CBPeripherals` will have a corresponding `Peripheral`. -/// This allows the integrating app to request communiication with a peripheral prior to it having been discovered -/// by CoreBluetooth, or if CoreBluetooth has invalidated all of its `CBPeripherals`. -public final class Peripheral: Identifiable, Hashable, @unchecked Sendable { - private let mutationLock = NSLock() - - /// Unique identifier for the peripheral as set by the integrating app. +/// The integrating app can also construct a `Peripheral` from a known identifier *before* it has been discovered — +/// for example, a wearable bound to the user's account — using ``init(id:)``. Such a snapshot has no +/// ``advertisement`` (and no live reference) until ``BluetoothActor`` matches it against a discovered `CBPeripheral`. +/// +/// Because it is a pure value type, a `Peripheral` is freely sendable across isolation domains and safe to hand to +/// the integrating app for UI display. +public struct Peripheral: Sendable, Identifiable, Hashable { + /// Unique identifier for the peripheral. + /// + /// When provided by the integrating app via ``init(id:)`` this is the app's own identifier. When resolved at + /// discovery time it is the peripheral's advertised name, its local name, or — as a fallback — the CoreBluetooth + /// identifier string. public let id: String - - /// The CoreBluetooth peripheral identifier, used to retrieve the peripheral after invalidation. - private var _peripheralIdentifier: UUID? - var peripheralIdentifier: UUID? { - mutationLock.lock(); defer { mutationLock.unlock() } - return _peripheralIdentifier - } - - private var _peripheral: CBPeripheral? - /// Reference to the CoreBluetooth peripheral object + + /// The CoreBluetooth identifier for the peripheral, used to retrieve it after invalidation. /// - /// - Warning: Intgrating app should not hold a strong reference! - var peripheral: CBPeripheral? { - mutationLock.lock(); defer { mutationLock.unlock() } - - return _peripheral - } - - /// The name advertised by the peripheral, if available - public var name: String? { - // No lock needed here since this is a computed property that accesses `peripheral` and `advertisementData` - // which already handle their own synchronization. - return peripheral?.name ?? advertisementData?[CBAdvertisementDataLocalNameKey] as? String - } - - /// Advertised service UUIDs - public var serviceUUIDs: [CBUUID]? { - // No lock needed here since this is a computed property that accesses `advertisementData` - // which already handles its own synchronization. - advertisementData?[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] - } - - private var _rssi: Int? - /// Signal strength indicator (RSSI) of the most recent advertisement - public var rssi: Int? { - mutationLock.lock(); defer { mutationLock.unlock() } - - // Return the cached RSSI value if available. - return _rssi - } - - private var _advertisementData: [String: Any]? - /// Complete advertisement data dictionary from the most recent discovery - public var advertisementData: [String: Any]? { - mutationLock.lock(); defer { mutationLock.unlock() } - - // Return the cached advertisement data if available. - return _advertisementData + /// `nil` for an app-constructed peripheral that has not yet been discovered. + public let cbIdentifier: UUID? + /// The name advertised by the peripheral, if available. + public let name: String? + + /// Signal strength indicator (RSSI) of the most recent advertisement. + public let rssi: Int? + + /// The timestamp when the peripheral was last seen. + public let lastSeen: Date? + + /// The typed advertisement data from the most recent discovery. + /// + /// `nil` until the peripheral has been discovered. Advertisement data is transient, per-discovery information; it + /// is not the peripheral's connected GATT service catalog. + public let advertisement: AdvertisementData? + + /// Registers a known peripheral before it has been discovered. + /// + /// Use this when the integrating app already has a stable identifier for a peripheral — such as a device bound to + /// the user's account — and wants ReliaBLE to match it against the corresponding `CBPeripheral` once discovered. + /// The resulting snapshot has no ``cbIdentifier``, ``name``, ``rssi``, ``lastSeen``, or ``advertisement`` until + /// discovery populates them. + /// + /// - Parameter id: The integrating app's unique identifier for the peripheral. + public init(id: String) { + self.init(id: id, cbIdentifier: nil, name: nil, rssi: nil, lastSeen: nil, advertisement: nil) } - - private var _lastSeen: Date? - /// The timestamp when the peripheral was last seen - public var lastSeen: Date? { - mutationLock.lock(); defer { mutationLock.unlock() } - - // Return the cached last seen date if available. - return _lastSeen - } - - /// Create a peripheral with a unique identifier and optional CoreBluetooth peripheral data. The integrating app - /// should use this initializer to create a `Peripheral` instance when it has a unique identifier for a peripheral - /// but has not yet discovered the peripheral with CoreBluetooth. ``BluetoothActor`` will update the instance - /// with CoreBluetooth data when the peripheral is discovered. + + /// Creates a fully-specified peripheral snapshot. Used internally by ``BluetoothActor`` at discovery time. + /// /// - Parameters: - /// - id: Unique identifier for the peripheral as set by the integrating app. - /// - peripheral: Reference to the CoreBluetooth `CBPeripheral` object - /// - advertisementData: Complete advertisement data dictionary from the most recent discovery - /// - rssi: Signal strength indicator (RSSI) of the most recent advertisement - public init(id: String, peripheral: CBPeripheral? = nil, advertisementData: [String: Any]? = nil, rssi: Int? = nil) { + /// - id: Unique identifier for the peripheral. + /// - cbIdentifier: The CoreBluetooth identifier, used to re-resolve the live peripheral after invalidation. + /// - name: The name advertised by the peripheral, if available. + /// - rssi: Signal strength indicator (RSSI) of the most recent advertisement. + /// - lastSeen: The timestamp when the peripheral was last seen. + /// - advertisement: The typed advertisement data from the most recent discovery. + init( + id: String, + cbIdentifier: UUID? = nil, + name: String? = nil, + rssi: Int? = nil, + lastSeen: Date? = nil, + advertisement: AdvertisementData? = nil + ) { self.id = id - - mutationLock.name = "Peripheral-\(id)-MutationLock" - - _peripheral = peripheral - _peripheralIdentifier = peripheral?.identifier - _rssi = rssi - _advertisementData = advertisementData - - if peripheral != nil && rssi != nil { - // Only set the last seen date if we have a valid CBPeripheral and RSSI value. - // This indicates that we have received an advertisement from the peripheral. - _lastSeen = Date() - } - } - - func update(cbPeripheral: CBPeripheral, advertisementData: [String: Any]? = nil, rssi: Int? = nil) { - mutationLock.lock(); defer { mutationLock.unlock() } - - _peripheral = cbPeripheral - _peripheralIdentifier = cbPeripheral.identifier - _advertisementData = advertisementData - _rssi = rssi - - if rssi != nil { - // Only set the last seen date if we have a valid RSSI value. - // This indicates that we have received an advertisement from the peripheral. - _lastSeen = Date() - } - } - - /// Invalidates the CoreBluetooth peripheral reference - func invalidateCBPeripheral() { - mutationLock.lock(); defer { mutationLock.unlock() } - - _peripheral = nil + self.cbIdentifier = cbIdentifier + self.name = name + self.rssi = rssi + self.lastSeen = lastSeen + self.advertisement = advertisement } - + public func hash(into hasher: inout Hasher) { hasher.combine(id) } - + public static func == (lhs: Peripheral, rhs: Peripheral) -> Bool { - // No need to compare `CBPeripheral` objects or identifiers, since the `id` is unique and matching between - // `Peripheral` and `CBPeripheral` instances are handled internally. + // Equality keys on `id` only: the identifier is unique and the matching between `Peripheral` snapshots and + // their live `CBPeripheral` is handled internally by ``BluetoothActor``. return lhs.id == rhs.id } } diff --git a/Sources/ReliaBLE/Models/PeripheralError.swift b/Sources/ReliaBLE/Models/PeripheralError.swift new file mode 100644 index 0000000..de3ca41 --- /dev/null +++ b/Sources/ReliaBLE/Models/PeripheralError.swift @@ -0,0 +1,38 @@ +// +// PeripheralError.swift +// ReliaBLE +// +// Created by Justin Bergen on 6/13/26. +// +// Copyright (c) 2026 Five3 Apps, LLC +// +// 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: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// 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. + +import Foundation + +/// Errors thrown by peripheral operations such as ``ReliaBLEManager/connect(to:)``. +public enum PeripheralError: Error, Sendable { + /// The peripheral is no longer known to the library. + /// + /// A ``Peripheral`` is a value snapshot captured at discovery time. The live CoreBluetooth peripheral it refers to + /// is held inside ``BluetoothActor`` keyed by ``Peripheral/id``. If that reference has since been invalidated (for + /// example, after Bluetooth reset) the snapshot is stale and operations that require the live peripheral throw + /// this error. + case notFound +} diff --git a/Sources/ReliaBLE/ReliaBLEManager.swift b/Sources/ReliaBLE/ReliaBLEManager.swift index 5a0717c..9a7a662 100644 --- a/Sources/ReliaBLE/ReliaBLEManager.swift +++ b/Sources/ReliaBLE/ReliaBLEManager.swift @@ -102,6 +102,23 @@ public class ReliaBLEManager { await bluetoothManager.stopScanning() } + // MARK: - Connection + + /// Initiates a connection to a previously discovered peripheral. + /// + /// The ``Peripheral`` is a value snapshot captured at discovery time. This method forwards its ``Peripheral/id`` + /// to the live CoreBluetooth peripheral held internally and requests a connection. + /// + /// - Parameter peripheral: A peripheral previously delivered via ``discoveredPeripherals``. + /// - Throws: ``PeripheralError/notFound`` if the peripheral's live reference has been invalidated (a stale + /// snapshot). + /// + /// - Note: This currently only initiates the connection request. The full connection lifecycle is deferred to a + /// later release. + public func connect(to peripheral: Peripheral) async throws { + try await bluetoothManager.connect(to: peripheral) + } + func testFunction() -> String { return "Hello, this is ReliaBLE!" } diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index f420a97..6512a6e 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -32,3 +32,22 @@ import Testing let package = ReliaBLEManager() #expect(package.testFunction() == "Hello, this is ReliaBLE!", "Incorrect response string") } + +@Test func peripheralIsSendable() async throws { + let peripheral = Peripheral(id: "sendable-id") + + // Capturing the value in a `Task.detached` closure is a compile-time proof that + // `Peripheral` is `Sendable` — the closure crosses an isolation boundary. + let capturedId = await Task.detached { peripheral.id }.value + + #expect(capturedId == "sendable-id") +} + +@Test func connectToUnknownPeripheralThrowsNotFound() async throws { + let manager = ReliaBLEManager() + let staleSnapshot = Peripheral(id: "never-discovered") + + await #expect(throws: PeripheralError.self) { + try await manager.connect(to: staleSnapshot) + } +} diff --git a/docs/plans/peripheral-sendable-struct-2026-06-13.md b/docs/plans/peripheral-sendable-struct-2026-06-13.md new file mode 100644 index 0000000..fd48cf2 --- /dev/null +++ b/docs/plans/peripheral-sendable-struct-2026-06-13.md @@ -0,0 +1,171 @@ +# Peripheral → Sendable Value Struct + AdvertisementData: Plan (Step 2 of 5) +*Issue #17 · 2026-06-13* + +## Goal + +Replace the `@unchecked Sendable` `Peripheral` class — which leaks `CBPeripheral?` and `[String: Any]?` through its public surface under a false `Sendable` promise — with a true `Sendable` value struct. Introduce a strongly-typed `AdvertisementData` struct to replace `[String: Any]`. Keep the live `CBPeripheral` reference inside `BluetoothActor` in an `id`-keyed registry that never escapes the actor. Add `id`-based operation entry points (`connect(to:)`) that throw `PeripheralError.notFound` on a stale snapshot. Fixes **Cluster 3** of the Swift 6 concurrency audit. + +This is **Step 2 of 5**. Combine → AsyncStream is Step 3 (#12); `ReliaBLEManager` `Sendable` conformance is Step 4 (#18). The `AnyPublisher` surface stays in this PR. + +## Background + +Step 1 (#13) is merged: `@globalActor BluetoothActor` and `BluetoothDelegateShim` already exist and own all BLE state. There is **no** `PeripheralManager` class in the library — the issue's item #4 refers to work already absorbed into `BluetoothActor` in Step 1. The registry today is the actor's `discoveredPeripherals: [Peripheral]` array. + +**Current `Peripheral`** (`Sources/ReliaBLE/Models/Peripheral.swift`): +- `public final class Peripheral: Identifiable, Hashable, @unchecked Sendable`, NSLock-guarded. +- Stored: `id: String`, `_peripheralIdentifier: UUID?`, `_peripheral: CBPeripheral?`, `_rssi: Int?`, `_advertisementData: [String: Any]?`, `_lastSeen: Date?`. +- Computed: `name` (falls back to `advertisementData[CBAdvertisementDataLocalNameKey]`), `serviceUUIDs` (from `advertisementData[CBAdvertisementDataServiceUUIDsKey]`). +- Mutation API: `init(id:peripheral:advertisementData:rssi:)`, `update(cbPeripheral:advertisementData:rssi:)` (:129), `invalidateCBPeripheral()` (:145). +- Equality/hash already key on `id` only. + +**Discovery flow** (`Sources/ReliaBLE/BluetoothActor.swift`): +- `BluetoothDelegateShim.centralManager(_:didDiscover:advertisementData:rssi:)` (:344) wraps non-Sendable values in `SendableWrapper` and hops to the actor. +- `handlePeripheralDiscovered(_:advertisementData:rssi:)` (:266–305): builds `PeripheralDiscoveryEvent` (:280) from raw `[String: Any]`; derives `identifier = cbPeripheral.name ?? advertisementData[localName] ?? cbPeripheral.identifier.uuidString`; looks up an existing `Peripheral` by `id`/`peripheralIdentifier` and calls `update(...)`, else constructs a new `Peripheral` (:302) and appends + publishes. +- `refreshPeripherals()` (:~326) obtains `CBPeripheral`s via `centralManager.retrievePeripherals(withIdentifiers:)` and calls `update(...)`. + +**Public surface today** (`ReliaBLEManager.swift`): `state`, `currentState`, `authorizeBluetooth()`, `peripheralDiscoveries: AnyPublisher`, `discoveredPeripherals: AnyPublisher<[Peripheral], Never>`, `startScanning(services:)`, `stopScanning()`. **No** `connect`/`disconnect` exists anywhere in the library yet. + +**`PeripheralDiscoveryEvent`** (`Models/Events/PeripheralDiscoveryEvent.swift`): public struct, also stores `advertisementData: [String: Any]` (:49) with computed `serviceUUIDs` (:39). It does *not* wrap a `Peripheral`. This is a **second** `[String: Any]` leak in the public surface, converted to `AdvertisementData` in this issue. + +**Errors**: only `AuthorizationError` exists (`BluetoothManager.swift:197`). No `PeripheralError`. + +**Mock target**: `CoreBluetoothMockAliases.swift` already aliases `CBUUID = CBMUUID` (:42) and `CBPeripheral = CBMPeripheral`. There are **zero** `@retroactive`/retroactive-`Sendable` conformances anywhere in `Sources/`. + +## Approach + +### `Peripheral` → pure value struct + +```swift +public struct Peripheral: Sendable, Identifiable, Hashable { + public let id: String // app-supplied or CB UUID string + public let cbIdentifier: UUID? // CB's own identifier, for retrieval + public let name: String? + public let rssi: Int? + public let lastSeen: Date? + public let advertisement: AdvertisementData? // nil until discovered (see Addendum) + + public init(id: String) // app-facing pre-discovery construction (see Addendum) + // internal full init used by BluetoothActor at discovery time +} +``` + +- No `CBPeripheral`, no `[String: Any]`, no `NSLock`. `Equatable`/`Hashable` key on `id` only (matches today; lets `AdvertisementData` stay out of the hash — see below). +- `name` becomes a stored field, resolved once at discovery time (`cbPeripheral.name ?? advertisement.localName`). +- `cbIdentifier` replaces the internal `peripheralIdentifier`; it is the lookup bridge back to the live `CBPeripheral`. +- **As-built:** `advertisement` shipped as `AdvertisementData?` and a `public init(id:)` was restored — see the Addendum at the end of this document. + +### `AdvertisementData` — strongly-typed, extracted once + +```swift +public struct AdvertisementData: Sendable, Hashable { + public let localName: String? + public let serviceUUIDs: [CBUUID] + public let manufacturerData: Data? + public let txPowerLevel: Int? + public let isConnectable: Bool? + public let serviceData: [CBUUID: Data] + public let overflowServiceUUIDs: [CBUUID] + public let solicitedServiceUUIDs: [CBUUID] +} +``` + +A single internal `init(rawAdvertisementData: [String: Any])` performs all `CBAdvertisementData*Key` extraction at discovery time, inside the actor. `[String: Any]` never leaves the actor. + +**Decision:** typed-only for now — no `rawAdvertisement: [String: any Sendable]` hatch. A dict of `any Sendable` is neither `Hashable` nor `Equatable` and would force hand-rolled conformance or dropping `Hashable`; the clean typed surface ships now and a raw hatch can be added in a follow-up if integrators need vendor-extension keys. + +### `CBPeripheral` registry inside `BluetoothActor` + +Add an actor-private `var cbPeripherals: [String: CBPeripheral] = [:]` keyed by `Peripheral.id`. The mutable reference never escapes the actor. The existing `discoveredPeripherals` array stays `[Peripheral]` (now value snapshots). Three existing paths must change together (all in the same PR, or the build breaks): + +- **`handlePeripheralDiscovered`** (:266–305): today the two lookups (`$0.id == identifier`, then `$0.peripheralIdentifier == cbPeripheral.identifier`) call `existing.update(...)`. With value structs, find the index and **replace** (`discoveredPeripherals[idx] = rebuilt`), and set `cbPeripherals[id] = cbPeripheral`. The lookup-before-append already guarantees `id` uniqueness, so the array stays duplicate-free. The method's hand-inlining (and its `// TODO: Step 2 — refactor once Peripheral is a Sendable value type` note) exists only because `CBPeripheral` couldn't cross actor-method boundaries; with the value struct, extraction can move into a shared helper. +- **`invalidatePeripherals`** (:318): drops the `peripheral.invalidateCBPeripheral()` loop — it becomes `cbPeripherals.removeAll()` (the value snapshots hold no CB ref to clear), then re-emit. +- **`refreshPeripherals`** (:326): replace `peripheralIdentifier`/`p.update(cbPeripheral:)` with `cbIdentifier`-based retrieval that re-populates `cbPeripherals[id] = cbPeripheral` for matching entries. No struct mutation. + +### `id`-keyed operations + `PeripheralError` + +```swift +public enum PeripheralError: Error, Sendable { case notFound } + +// ReliaBLEManager +public func connect(to peripheral: Peripheral) async throws + +// BluetoothActor +func connect(id: String) async throws { + guard let cb = cbPeripherals[id] else { throw PeripheralError.notFound } + centralManager?.connect(cb, options: nil) +} +``` + +`connect(to:)` forwards the stable `id`; the actor looks up the live `CBPeripheral` and throws `PeripheralError.notFound` if the registry no longer holds it (stale snapshot after invalidation). **Decision:** scope is the entry point + lookup + `notFound` only — `centralManager.connect(cb, options: nil)` is fired, but the full connection lifecycle (didConnect/didDisconnect handling, a connection-state surface) is deferred to a later issue. + +### `CBUUID: Sendable` + +Add `extension CBUUID: @retroactive @unchecked Sendable {}` with a comment that `CBUUID` is effectively immutable after init. **Risk:** in `ReliaBLEMock`, `CBUUID` resolves to `CBMUUID`; if Nordic already declares `CBMUUID: Sendable`, the extension is a redundant-conformance compile error in that target. Verify per target; guard with a parallel/conditional declaration only if needed. + +## Work Items + +1. **`AdvertisementData`** — new file `Sources/ReliaBLE/Models/AdvertisementData.swift`. Struct + internal `init(rawAdvertisementData: [String: Any])` doing all key extraction (localName, serviceUUIDs, manufacturerData, txPowerLevel, isConnectable, serviceData, overflow/solicited UUIDs). Typed-only, clean `Hashable` — no raw escape hatch. **Build this first** — Items 5 and 7 both consume the same extraction (build the `AdvertisementData` once in `handlePeripheralDiscovered`, feed it to both the `Peripheral` and the event). +2. **`CBUUID` Sendable** — add the `@retroactive @unchecked Sendable` extension (own small file, e.g. `Sources/ReliaBLE/CBUUID+Sendable.swift`). It lives in the **shared** source tree (no `Package.swift` exclusion like `CBCentralManagerFactory.swift`), so in `ReliaBLEMock` it applies to `CBMUUID` via the existing `CBUUID = CBMUUID` alias. Build both targets; **if** Nordic already declares `CBMUUID: Sendable` the extension is a redundant-conformance error in the mock target — only then guard it (e.g. `#if`/move the mock-side declaration into `CoreBluetoothMockAliases.swift`). +3. **`Peripheral` struct rewrite** — replace the class in `Peripheral.swift`. Remove NSLock, `_peripheral`, `update`, `invalidateCBPeripheral`. New stored fields per Approach. Keep `Identifiable`/`Hashable` (by `id`). +4. **`PeripheralError`** — add `public enum PeripheralError: Error, Sendable { case notFound }` (new file or alongside `AuthorizationError`). +5. **`BluetoothActor` rewrite of discovery** — add the `cbPeripherals: [String: CBPeripheral]` registry and rewrite all three paths per Approach: `handlePeripheralDiscovered` (extract once, replace-by-index, set registry), `invalidatePeripherals` (clear registry), `refreshPeripherals` (use `cbIdentifier`, repopulate registry). These must land in the same PR — they currently call the class-only `update()`/`invalidateCBPeripheral()`/`peripheralIdentifier`, which all disappear. +6. **`connect(to:)` entry points** — add `connect(id:)` (and the registry lookup + `PeripheralError.notFound`) on `BluetoothActor`; expose `connect(to:)` on `ReliaBLEManager`. Entry-point-only (no connection lifecycle) per Decision 3. +7. **`PeripheralDiscoveryEvent`** — replace `advertisementData: [String: Any]` with `advertisement: AdvertisementData`; drop the computed `serviceUUIDs` (now on `AdvertisementData`). Build it from the same extracted `AdvertisementData` in `handlePeripheralDiscovered`. Demo reads only `.id`/`.name`/`.rssi` — unaffected. +8. **Unit test** — capture a `Peripheral` inside a `Task.detached` closure (compile-time `Sendable` proof); assert `connect` on a stale id throws `PeripheralError.notFound`. +9. **Verify + docs** — `swift build` + `swift test` for `ReliaBLE`, `ReliaBLEMock`, `ReliaBLETests`; confirm the Demo still builds (it reads only `.id`/`.name`/`.lastSeen` + event `.id`/`.name`/`.rssi`, all preserved). Confirm no `CBPeripheral` / `[String: Any]` in any public type. Update the DocC catalog for the new public surface (`Peripheral`, `AdvertisementData`, `connect(to:)`, `PeripheralError`) per the project's DocC rule. + +## Decisions (resolved at planning) + +1. **`PeripheralDiscoveryEvent` is converted in #17** — its `[String: Any]` becomes `AdvertisementData`, honoring "no `[String: Any]` in any public type." +2. **`AdvertisementData` is typed-only** — no `rawAdvertisement` escape hatch (would break `Hashable`); a raw hatch is a possible follow-up. +3. **`connect(to:)` is entry-point-only** — lookup + `centralManager.connect` + `PeripheralError.notFound`; full connection lifecycle deferred to a later issue. + +## Open Questions + +None blocking. One implementation-time risk: the `CBUUID: @retroactive @unchecked Sendable` extension may collide with an upstream `CBMUUID: Sendable` in `ReliaBLEMock` (redundant conformance). Resolve at the build step (Work Item 2) — guard per target only if the error appears. + +## References + +- Investigation report: `docs/investigations/swift6-concurrency-audit-2026-05-13.md` — §3, Cluster 3, Recommendations §B (target design at lines ~697–760). +- Step 1 plan: `docs/plans/bluetooth-actor-migration-2026-06-08.md` +- Issue #17 (this); parent #10; depends on #13 (merged, PR #23). Hands off to #12 (Step 3) and #18 (Step 4). +- Key files: `Sources/ReliaBLE/Models/Peripheral.swift`, `Sources/ReliaBLE/BluetoothActor.swift` (:266–305, :326, :344), `Sources/ReliaBLE/Models/Events/PeripheralDiscoveryEvent.swift`, `Sources/ReliaBLE/ReliaBLEManager.swift`, `Sources/ReliaBLEMock/CoreBluetoothMockAliases.swift`. + +## Addendum — As-Built Notes (2026-06-15) + +Records where the shipped implementation deviates from or clarifies the plan above. The plan body is left intact as the original intent; this section is authoritative where the two differ. + +### 1. `Peripheral` is publicly constructible (reversed from the original cut) + +The original draft made all initializers internal, which removed the old `public init`. This was reversed during review: + +- Added **`public init(id: String)`** so an integrating app can register a known peripheral *before* discovery — e.g. a wearable bound to the user's account — to be matched against its `CBPeripheral` once discovered (the FR-8.5 custom-advertised-ID path, still unimplemented). +- The fully-specified initializer (`id:cbIdentifier:name:rssi:lastSeen:advertisement:`) remains **internal**, used only by `BluetoothActor` at discovery time. +- `AdvertisementData.init(rawAdvertisementData:)` remains **internal** — the "extract the `[String: Any]` exactly once, inside the actor" invariant is preserved. Apps construct a `Peripheral` by `id`; only the library ever builds an `AdvertisementData`. + +### 2. `Peripheral.advertisement` is optional (`AdvertisementData?`) + +Because an app-constructed, pre-discovery `Peripheral` has no advertisement yet, `advertisement` shipped as `AdvertisementData?` (`nil` until discovery populates it). This honestly distinguishes "not yet seen advertising" from "advertised nothing," and avoids needing a public empty `AdvertisementData` initializer. Likewise `cbIdentifier`/`name`/`rssi`/`lastSeen` are empty on an app-constructed snapshot. + +- `PeripheralDiscoveryEvent.advertisement` stays **non-optional** — an event always originates from a received advertisement packet. +- Consumer impact: reading advertisement off a discovered peripheral now optional-chains, e.g. `peripheral.advertisement?.serviceUUIDs ?? []`. + +### 3. Service UUIDs stay on `AdvertisementData` (Work Item 7 confirmed, no convenience accessor) + +Considered, then rejected, adding `serviceUUIDs` (or an `advertisedServiceUUIDs` convenience) onto `Peripheral`. Rationale: advertised service UUIDs are a pre-connection *hint* from the advertisement packet and are distinct from `CBPeripheral.services` (the post-connection GATT catalog). Keeping them on `advertisement` (alongside `overflowServiceUUIDs`/`solicitedServiceUUIDs`) leaves `Peripheral.services` free to mean the real connected GATT catalog in a future connection-lifecycle step. + +### 4. `SendableWrapper` retained (not removed in Step 2) + +The plan's Background implied the `SendableWrapper` hop might go away once `Peripheral` is a value type. It was **kept**: the raw `CBPeripheral` and `[String: Any]` advertisement dictionary delivered by the delegate are still non-`Sendable` and must cross the delegate-queue → actor hop before extraction into `Peripheral`/`AdvertisementData` *inside* the actor. The doc comments were updated to reflect this. + +### 5. `CBUUID: @retroactive @unchecked Sendable` — no mock-target collision + +The Open Question risk (redundant-conformance error if `CoreBluetoothMock` already declares `CBMUUID: Sendable`) did **not** materialize. Both `ReliaBLE` and `ReliaBLEMock` compile the shared extension cleanly; no per-target guard was needed. + +### 6. `connect(id:)` guards a missing central manager + +In addition to the `PeripheralError.notFound` registry-lookup throw, `connect(id:)` guards `centralManager == nil` with a log-and-no-op, matching the existing `startScanning`/`stopScanning` convention. In practice unreachable (the registry is only populated while a central manager exists), but it prevents the API from silently reporting success with nothing to act on. + +### 7. Tests + +Shipped as planned: a `Task.detached`-capture `Sendable` proof (`peripheralIsSendable`) and a stale-snapshot `connect` test (`connectToUnknownPeripheralThrowsNotFound`, asserting `PeripheralError.self`). Both use the new `public init(id:)`. A broader actor/registry test harness (driving the mock central to emit discoveries) was noted as a possible follow-up, out of scope here. diff --git a/docs/reviews/peripheral-sendable-struct-plan-critique-2026-06-13.md b/docs/reviews/peripheral-sendable-struct-plan-critique-2026-06-13.md new file mode 100644 index 0000000..0836986 --- /dev/null +++ b/docs/reviews/peripheral-sendable-struct-plan-critique-2026-06-13.md @@ -0,0 +1,33 @@ +# Critique: Peripheral → Sendable Value Struct Plan (2026-06-13) + +**Scope**: Plan for ReliaBLE #17 (Peripheral value struct + AdvertisementData + CBPeripheral registry). Review limited to the three named seams. + +## 1. Under-specified Seams + +**handlePeripheralDiscovered rebuild + registry sync** (`BluetoothActor.swift:266-305`) +- Current code mutates in place via `existing.update(...)` and `discoveredPeripherals.append(new)`. Plan says "rebuild value Peripheral, replace element" but never states the exact lookup/replacement logic or how `cbPeripherals[id] = cbPeripheral` is kept consistent with the two identifier checks (`id` vs `peripheralIdentifier`). +- `invalidatePeripherals()` (318) and `refreshPeripherals()` (326) still call the old class methods; plan gives no replacement for either path. `refreshPeripherals` uses `peripheralIdentifier` which disappears from the value struct, so the `identifiers` collection and the subsequent `first(where:)` will break. + +**discoveredPeripherals: AnyPublisher emission** +- Plan claims value-semantic snapshots "rebuilt by replacing elements" will keep the publisher working. It does not address whether `discoveredPeripheralsSubject.send(discoveredPeripherals)` (called after every mutation today) still fires correctly when the array contains immutable structs, or whether duplicate `id` entries can appear after a replacement. + +**CBUUID retroactive Sendable vs CBMUUID collision** +- Plan correctly flags the risk in the mock target. It does not state where the guarded declaration lives or whether the production `CBUUID+Sendable.swift` must be excluded from `ReliaBLEMock` target the same way `CBCentralManagerFactory.swift` is. + +## 2. Contradictions / Missing Dependencies + +- Work Item 5 requires `cbPeripherals` registry, yet the current `invalidatePeripherals`/`refreshPeripherals` paths (already merged in Step 1) are not listed as dependents. They must be rewritten in the same PR or the build will be broken. +- `PeripheralDiscoveryEvent` conversion (Work Item 7) depends on `AdvertisementData` (Item 1) but also on the same extraction code inside `handlePeripheralDiscovered`. No ordering or shared helper is declared. +- `PeripheralError` (Item 4) is required for `connect(to:)` (Item 6), but `connect` is the only consumer; if Item 6 slips, Item 4 is dead weight. + +## 3. Over-planning + +- Work Item 8 ("Demo adaptation") can be deleted. The plan already asserts that `CentralViewModel` only reads `.id`/`.name`/`.lastSeen` and `.rssi`, all of which survive the struct rewrite. No signature change touches the demo. +- Work Item 10's DocC update is boilerplate; it belongs in the PR description, not a tracked work item. + +## 4. Questions That Change Implementation Order + +- Must `invalidatePeripherals` and `refreshPeripherals` be rewritten before or after the `Peripheral` struct change? If the registry must stay in sync on power-cycle, the order is forced. +- Is `discoveredPeripheralsSubject` allowed to emit duplicate `id` values after a replacement, or must the plan guarantee uniqueness? The answer determines whether a dictionary or array-with-replace is used. + +**Recommendation**: Resolve the three seams above and delete Work Items 8 and 10 before scheduling. \ No newline at end of file From 3fcda5429e7db89b2c1cb95f4a42692cec27afe1 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Fri, 19 Jun 2026 17:55:52 -0600 Subject: [PATCH 13/27] Narrowed the scope of `SendableWrapper` I don't want this to be a tempting escape hatch later --- Sources/ReliaBLE/BluetoothActor.swift | 38 +++++++++++-------- .../bluetooth-actor-migration-2026-06-08.md | 2 + .../peripheral-sendable-struct-2026-06-13.md | 10 +++++ 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index cfb1167..84c1022 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -30,17 +30,20 @@ import Foundation // MARK: - Sendable Bridging Helper -/// Wraps a non-`Sendable` value for safe transfer across actor isolation boundaries. +/// Carries one discovery callback's raw CoreBluetooth payload across the nonisolated +/// delegate-queue → ``BluetoothActor`` hop. /// -/// The caller must ensure the wrapped value is not mutated concurrently from multiple -/// isolation domains. This wrapper is used only for the transitional hop between -/// CoreBluetooth's delegate queue and ``BluetoothActor``. +/// The `CBPeripheral` and `[String: Any]` advertisement dictionary delivered by the delegate are non-`Sendable`, but +/// the payload is read exactly once—on the delegate queue, before the hop—and never mutated, so ferrying it into the +/// actor is safe. ``Peripheral`` and ``AdvertisementData`` (the `Sendable` value types the app sees) are not +/// extracted until inside the actor, preserving the invariant that the raw payload is touched only there. /// -/// Still required: the raw `CBPeripheral` and `[String: Any]` advertisement dictionary delivered by the delegate -/// are non-`Sendable` and must cross into the actor. ``Peripheral`` itself is now a `Sendable` value type, but the -/// CoreBluetooth payload it is built from is not extracted until inside the actor. -private struct SendableWrapper: @unchecked Sendable { - let value: T +/// This is a single-purpose `@unchecked Sendable` boundary rather than a general-purpose wrapper, so the unchecked +/// assertion stays scoped to exactly this transfer. +private struct DiscoveryPayload: @unchecked Sendable { + let peripheral: CBPeripheral + let advertisementData: [String: Any] + let rssi: Int } // MARK: - BluetoothActor @@ -410,11 +413,16 @@ final class BluetoothDelegateShim: NSObject, CBCentralManagerDelegate { advertisementData: [String: Any], rssi RSSI: NSNumber ) { - // Wrap the non-Sendable CBPeripheral and advertisement dictionary for the actor isolation hop. - // They are extracted into Sendable types (Peripheral / AdvertisementData) inside the actor. - let p = SendableWrapper(value: peripheral) - let ad = SendableWrapper(value: advertisementData) - let rssi = RSSI.intValue - Task { @BluetoothActor in await BluetoothActor.shared.handlePeripheralDiscovered(p.value, advertisementData: ad.value, rssi: rssi) } + // Ferry the non-Sendable CBPeripheral and advertisement dictionary across the actor isolation hop in a + // single-purpose payload. They are extracted into Sendable types (Peripheral / AdvertisementData) inside + // the actor. + let payload = DiscoveryPayload(peripheral: peripheral, advertisementData: advertisementData, rssi: RSSI.intValue) + Task { @BluetoothActor in + await BluetoothActor.shared.handlePeripheralDiscovered( + payload.peripheral, + advertisementData: payload.advertisementData, + rssi: payload.rssi + ) + } } } diff --git a/docs/plans/bluetooth-actor-migration-2026-06-08.md b/docs/plans/bluetooth-actor-migration-2026-06-08.md index a6d9cf2..8fe73de 100644 --- a/docs/plans/bluetooth-actor-migration-2026-06-08.md +++ b/docs/plans/bluetooth-actor-migration-2026-06-08.md @@ -19,6 +19,8 @@ This is **Step 1 of 5**. The public `AnyPublisher` surface and the `Peripheral` **Step handoffs (do not implement here):** - Step 2 (#17): `Peripheral` → `Sendable` value struct; `CBPeripheral` registry fully actor-owned - Step 3 (#12): Replace `AnyPublisher` with `AsyncStream`; remove all `@unchecked Sendable`/`nonisolated(unsafe)` Combine workarounds from this step + +> **Note (added 2026-06-19):** This plan elsewhere assumes the `SendableWrapper` delegate-hop is removed in Step 2. That turned out to be a misattribution — see the as-built addendum in `docs/plans/peripheral-sendable-struct-2026-06-13.md` (items #4 and #8) for why the hop is still required. This plan is left otherwise intact as the original Step 1 intent. - Step 4 (#18): `ReliaBLEManager` → `nonisolated Sendable`; collapse `BluetoothManager` into the actor - Step 5 (#19): `-strict-concurrency=complete` flag flip + logging polish + DocC diff --git a/docs/plans/peripheral-sendable-struct-2026-06-13.md b/docs/plans/peripheral-sendable-struct-2026-06-13.md index fd48cf2..e9fa8d7 100644 --- a/docs/plans/peripheral-sendable-struct-2026-06-13.md +++ b/docs/plans/peripheral-sendable-struct-2026-06-13.md @@ -158,6 +158,8 @@ Considered, then rejected, adding `serviceUUIDs` (or an `advertisedServiceUUIDs` The plan's Background implied the `SendableWrapper` hop might go away once `Peripheral` is a value type. It was **kept**: the raw `CBPeripheral` and `[String: Any]` advertisement dictionary delivered by the delegate are still non-`Sendable` and must cross the delegate-queue → actor hop before extraction into `Peripheral`/`AdvertisementData` *inside* the actor. The doc comments were updated to reflect this. +**Why the Step 1 expectation was wrong.** The Step 1 plan (and the original `SendableWrapper` TODO comments) assumed the wrapper existed to ferry the non-`Sendable` `Peripheral` *class*, so making `Peripheral` a value type would remove it. That was a misattribution: the wrapper never carried a `Peripheral` — it carries the raw `CBPeripheral` and `[String: Any]` advertisement dictionary delivered on CoreBluetooth's nonisolated delegate queue. Those framework types stay non-`Sendable`, the architecture deliberately defers extraction into value types until *inside* the actor, and Step 2's new actor-owned `cbPeripherals: [String: CBPeripheral]` registry actually *requires* the live reference to reach the actor. So the hop survives regardless of `Peripheral`'s Sendability. An oracle review confirmed this and validated keeping the hop (do **not** extract in the shim or pass only a `UUID` — that would violate the invariant and trade a live reference for a racy `retrievePeripherals(withIdentifiers:)` lookup). + ### 5. `CBUUID: @retroactive @unchecked Sendable` — no mock-target collision The Open Question risk (redundant-conformance error if `CoreBluetoothMock` already declares `CBMUUID: Sendable`) did **not** materialize. Both `ReliaBLE` and `ReliaBLEMock` compile the shared extension cleanly; no per-target guard was needed. @@ -169,3 +171,11 @@ In addition to the `PeripheralError.notFound` registry-lookup throw, `connect(id ### 7. Tests Shipped as planned: a `Task.detached`-capture `Sendable` proof (`peripheralIsSendable`) and a stale-snapshot `connect` test (`connectToUnknownPeripheralThrowsNotFound`, asserting `PeripheralError.self`). Both use the new `public init(id:)`. A broader actor/registry test harness (driving the mock central to emit discoveries) was noted as a possible follow-up, out of scope here. + +### 8. `SendableWrapper` → `DiscoveryPayload`: keep the unchecked scope small (post-merge refinement, 2026-06-19) + +The same oracle review from item #4 recommended narrowing the unchecked assertion. The generic `private struct SendableWrapper: @unchecked Sendable` was replaced with a single-purpose `private struct DiscoveryPayload: @unchecked Sendable { let peripheral: CBPeripheral; let advertisementData: [String: Any]; let rssi: Int }` in `BluetoothActor.swift`. The shim now ferries one `DiscoveryPayload` across the hop instead of two generic wrappers. + +- **Decision — keep the solution scope small.** A generic `@unchecked Sendable` wrapper reads as a reusable escape hatch: it invites future call sites to bypass concurrency checking for *any* type, which muddies the waters over time and erodes the value of complete concurrency checking. The real invariant is narrow — "this one discovery payload is safe to ferry once into the actor." A single-purpose type names exactly that boundary, makes the unchecked assertion self-documenting, and keeps the unsafe surface confined to the one hop that genuinely needs it rather than normalizing a general-purpose unchecked tool. +- **No behavior change:** still a `@unchecked Sendable` ferry of the raw, read-once CoreBluetooth payload; extraction into `Peripheral`/`AdvertisementData` still happens inside the actor. Builds clean under `ReliaBLE` and `ReliaBLEMock`. +- **Not a Step 3 obligation:** the oracle confirmed AsyncStream cleanup (Step 3) removes the Combine `nonisolated(unsafe)` bridging, but does not inherently solve the non-`Sendable` CoreBluetooth payload hop. A future switch to `sending` parameters would be a targeted, compiler-verified refactor rather than a required goal. From 54deb258e9f1853a066e9542af6d9df39539a39f Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Fri, 19 Jun 2026 18:36:09 -0600 Subject: [PATCH 14/27] Apply suggestions from Copilot code review I reviewed and all seem reasonable but will need validated with a build and test. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Sources/ReliaBLE/BluetoothActor.swift | 8 ++++---- Sources/ReliaBLE/Models/Peripheral.swift | 1 - Sources/ReliaBLE/Models/PeripheralError.swift | 1 - Tests/ReliaBLETests/ReliaBLEManagerTests.swift | 7 ++++++- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index 84c1022..f1ca3c9 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -33,10 +33,10 @@ import Foundation /// Carries one discovery callback's raw CoreBluetooth payload across the nonisolated /// delegate-queue → ``BluetoothActor`` hop. /// -/// The `CBPeripheral` and `[String: Any]` advertisement dictionary delivered by the delegate are non-`Sendable`, but -/// the payload is read exactly once—on the delegate queue, before the hop—and never mutated, so ferrying it into the -/// actor is safe. ``Peripheral`` and ``AdvertisementData`` (the `Sendable` value types the app sees) are not -/// extracted until inside the actor, preserving the invariant that the raw payload is touched only there. +/// The `CBPeripheral` and `[String: Any]` advertisement dictionary delivered by the delegate are non-`Sendable`. +/// They are treated as immutable payload and are only accessed inside ``BluetoothActor`` after the hop, where they are +/// immediately converted into `Sendable` value snapshots (``Peripheral`` / ``AdvertisementData``). The raw payload is +/// never stored outside the actor. /// /// This is a single-purpose `@unchecked Sendable` boundary rather than a general-purpose wrapper, so the unchecked /// assertion stays scoped to exactly this transfer. diff --git a/Sources/ReliaBLE/Models/Peripheral.swift b/Sources/ReliaBLE/Models/Peripheral.swift index 706750f..654acaa 100644 --- a/Sources/ReliaBLE/Models/Peripheral.swift +++ b/Sources/ReliaBLE/Models/Peripheral.swift @@ -24,7 +24,6 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import CoreBluetooth import Foundation /// An immutable, `Sendable` value snapshot of a Bluetooth peripheral and its metadata. diff --git a/Sources/ReliaBLE/Models/PeripheralError.swift b/Sources/ReliaBLE/Models/PeripheralError.swift index de3ca41..da32cd5 100644 --- a/Sources/ReliaBLE/Models/PeripheralError.swift +++ b/Sources/ReliaBLE/Models/PeripheralError.swift @@ -24,7 +24,6 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import Foundation /// Errors thrown by peripheral operations such as ``ReliaBLEManager/connect(to:)``. public enum PeripheralError: Error, Sendable { diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 6512a6e..1dce403 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -47,7 +47,12 @@ import Testing let manager = ReliaBLEManager() let staleSnapshot = Peripheral(id: "never-discovered") - await #expect(throws: PeripheralError.self) { + do { try await manager.connect(to: staleSnapshot) + #expect(false, "Expected PeripheralError.notFound") + } catch PeripheralError.notFound { + // expected + } catch { + #expect(false, "Expected PeripheralError.notFound, got \(error)") } } From 3523f5918977e23a3446347357d94f1e8d1580b3 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 13 Jun 2026 16:27:46 -0600 Subject: [PATCH 15/27] Added PR #23 review report --- ...pilot-pr23-review-evaluation-2026-06-11.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/investigations/copilot-pr23-review-evaluation-2026-06-11.md diff --git a/docs/investigations/copilot-pr23-review-evaluation-2026-06-11.md b/docs/investigations/copilot-pr23-review-evaluation-2026-06-11.md new file mode 100644 index 0000000..594fa03 --- /dev/null +++ b/docs/investigations/copilot-pr23-review-evaluation-2026-06-11.md @@ -0,0 +1,123 @@ +# Investigation: Evaluating Copilot's Review Comments on PR #23 vs. the Audit & Step 1 Plan + +*2026-06-11 · PR #23 "Introduce BluetoothActor to serialize CoreBluetooth state (Step 1 of 5)"* + +## Summary + +Copilot left **9 inline comments** on PR #23. Evaluated against the build/test reality, the Swift 6 concurrency audit (`docs/investigations/swift6-concurrency-audit-2026-05-13.md`), and the Step 1 plan (`docs/plans/bluetooth-actor-migration-2026-06-08.md`): + +- **3 are correct and valuable** (#1 + its corollaries #2/#3): the PR converted `Peripheral.peripheralIdentifier` from a stored property to a computed `peripheral?.identifier`, which **regresses the documented "retrieve peripheral after invalidation" capability** — `refreshPeripherals()` can no longer find anything once peripherals are invalidated. +- **1 is trivially correct** (#5): a real typo (`CBPeriphal`). +- **2 are factually wrong about compilation** (#4, #6): both claim the code "will not compile" / violates Sendable, but `swift build` and `swift test` are **clean**. #6's *suggested pattern* nonetheless matches what the plan prescribed and is a legitimate quality improvement; #4's rationale is simply incorrect. +- **2 raise a legitimate-but-deliberately-deferred concern** (#7, #8): the `nonisolated(unsafe) var currentBluetoothState` data race is real in principle, but the plan explicitly accepts it as a transitional shortcut to be removed in Step 3. +- **1 is factually correct and worth acting on** (#9): `queue: nil` routes CoreBluetooth callbacks to the main thread — a real behavior change from the pre-PR dedicated serial queue. + +**Meta-finding:** Most of the Peripheral-related comments (#1–#5) are symptoms of **scope creep**. The Step 1 plan said `Peripheral` would be *untouched* — but that rested on an incorrect premise ("`Peripheral` is already `@unchecked Sendable`"). It was not. The author had to make `Peripheral` Sendable to satisfy the actor's `[Peripheral]` publisher, pulling Step 2 (#17) work forward as a `@unchecked Sendable` + `NSLock` stopgap rather than the audit's target "pure Sendable struct." The regression Copilot caught lives in that unplanned stopgap. + +## Symptoms / Inputs + +- 9 Copilot inline comments across `Sources/ReliaBLE/Models/Peripheral.swift` (5) and `Sources/ReliaBLE/BluetoothActor.swift` (4). +- PR description claims clean `swift build` and passing `swift test` under Swift 6 complete concurrency checking. +- Two governing docs: the audit (root cause) and the Step 1 plan (the contract this PR was meant to fulfill). + +## Background / Prior Research + +**Build/test reality (decisive for compile-claims):** +- `swift build` → `Build complete!` — no errors, no ReliaBLE warnings (only deprecation warnings from the Willow dependency). +- `swift test` → `correctFunction()` passed; `ReliaBLEMock` + `ReliaBLETests` build clean. + +**What the PR actually changed in `Peripheral.swift`** (`git diff 10-update-for-swift-concurrency-in-swift-6...HEAD`): +- Base: `public class Peripheral: Identifiable, Hashable` — **not** Sendable, no lock, no `invalidateCBPeripheral()`. +- Base `peripheralIdentifier`: **stored** `var peripheralIdentifier: UUID?`, set in `init` (`self.peripheralIdentifier = peripheral?.identifier`) and `update` (`self.peripheralIdentifier = cbPeripheral.identifier`). +- Head: `public final class … @unchecked Sendable`, `NSLock`-guarded backing storage, new `invalidateCBPeripheral()`, and `peripheralIdentifier` rewritten as **computed** `peripheral?.identifier`. + +**What the base used for the central-manager queue** (`git show …:BluetoothManager.swift`): +- `private let queue = DispatchQueue(label: "com.five3apps.relia-ble.bluetoothmanager", qos: .userInitiated)` passed as `CBCentralManagerFactory.instance(delegate: self, queue: queue, …)`. + +**Plan text that the PR contradicts:** +- "The public `AnyPublisher` surface and the `Peripheral` class are **untouched**; those are Steps 3 (#12) and 2 (#17)." +- "`Peripheral` is already `@unchecked Sendable` (`Peripheral.swift:41`), so `[Peripheral]` satisfies Sendable." → **false premise**; base `Peripheral` was a plain `class`. +- Shim pattern prescribed: "hops to the actor via `Task { @BluetoothActor in BluetoothActor.shared.handleXxx(...) }`." → implementation used bare `Task { await BluetoothActor.shared.handle…() }`. + +**Audit target for `Peripheral`** (§B): "Pure `Sendable` `struct` — no manual locking, no `@unchecked Sendable`," strongly-typed `AdvertisementData`, `CBPeripheral` confined to the actor. The PR's `@unchecked Sendable` + `NSLock` is an interim deviation from this target. + +## Per-Comment Evaluation + +### #1 — `Peripheral.swift:45` — `peripheralIdentifier` lost after invalidation — ✅ CORRECT, HIGH VALUE +Copilot: making `peripheralIdentifier` derive from `peripheral?.identifier` means it returns `nil` after `invalidateCBPeripheral()` clears `_peripheral`, breaking retrieve-after-invalidation. + +**Verdict: Confirmed and important.** Evidence chain: +- `Peripheral.swift:45-50` — `peripheralIdentifier` is now computed `peripheral?.identifier`. +- `Peripheral.swift:147-151` — `invalidateCBPeripheral()` sets `_peripheral = nil` ⇒ `peripheralIdentifier` becomes `nil`. +- `BluetoothActor.swift:332-341` — `refreshPeripherals()` does `discoveredPeripherals.compactMap { $0.peripheralIdentifier }`; after `invalidatePeripherals()` (`BluetoothActor.swift:324-330`, invoked from `handleCentralManagerStateUpdate` on `.resetting/.unsupported/.unauthorized`), every identifier is `nil`, so the `guard !identifiers.isEmpty` bails and **nothing is ever retrieved on the subsequent `.poweredOn`**. +- The class doc and the property's own doc-comment ("used to retrieve the peripheral after invalidation") describe exactly the capability this breaks. +- Base branch stored the UUID independently, so it survived invalidation. This is a true regression introduced by this PR. + +### #2 — `Peripheral.swift:121` — init must seed persisted identifier — ✅ CORRECT (corollary of #1) +The fix for #1 is to restore a stored `peripheralIdentifier` and initialize it from the incoming `CBPeripheral` in `init` (as the base did at line 80). Sound. + +### #3 — `Peripheral.swift:136` — `update(cbPeripheral:)` must refresh persisted identifier — ✅ CORRECT (corollary of #1) +Same fix; `update` should set the stored identifier from `cbPeripheral.identifier` (as the base did at line 93) so it persists across a later invalidation. Sound. + +### #4 — `Peripheral.swift:151` — "`nonisolated` won't compile on a regular class method" — ❌ INCORRECT RATIONALE +`swift build` is clean, so the categorical "will not compile" is **false**. `nonisolated` is legal on members of non-actor types; here it is merely **redundant** (`Peripheral` has no actor isolation to opt out of). Defensible advice: *remove it as redundant noise* — but Copilot's stated reason (compile failure) is wrong. + +### #5 — `Peripheral.swift:158` — typo `CBPeriphal` → `CBPeripheral` — ✅ CORRECT (trivial) +Confirmed at `Peripheral.swift:156`. Harmless but valid. + +### #6 — `BluetoothActor.swift:360` — shim hop forces non-Sendable args across the boundary — ⚠️ WRONG RATIONALE, RIGHT DIRECTION +Copilot claims the bare `Task { await BluetoothActor.shared.handlePeripheralDiscovered(p.value, …) }` forces `CBPeripheral`/`[String: Any]` to be Sendable "which they are not," implying a build break and that the `SendableWrapper` is ineffective. + +**Verdict: the compile-failure premise is refuted** — it builds clean; the `SendableWrapper` + region-based isolation is precisely what makes `p.value` sendable across the hop, so the wrapper *is* effective. **However**, Copilot's recommended shape — `Task { @BluetoothActor in … }` calling the handler synchronously inside actor isolation — is exactly the pattern the **Step 1 plan prescribed** ("`Task { @BluetoothActor in BluetoothActor.shared.handleXxx(...) }`"). The implementation diverged to a bare `Task { await … }`. Adopting Copilot's form would realign with the plan and would let the author drop the awkward "inlined to dodge a region-isolation false positive" workaround documented at `BluetoothActor.swift:280-289`. So: **act on the suggestion, ignore the rationale.** (The `SendableWrapper` is still needed either way for the closure capture.) + +### #7 — `BluetoothActor.swift:98` — `currentBluetoothState` data race — ⚠️ VALID IN PRINCIPLE, DELIBERATELY DEFERRED +`nonisolated(unsafe) var currentBluetoothState` is written on the actor executor (`broadcastState`, `BluetoothActor.swift:230-235`) and read off-actor via `BluetoothManager.currentState`. Concurrent read/write of a non-atomic value is a data race under Swift's memory model, and `BluetoothState` is a payloaded enum (`.unauthorized(.notDetermined)`) that can **tear** — so the plan's justification ("value semantics, no partial writes") is technically shaky and Copilot is arguably *more* correct than the plan here. + +**But** the plan explicitly chose this as a time-boxed transitional shortcut tagged `// TODO: removed in Step 3`, consistent with the audit's "wrapped in `@unchecked Sendable` where needed to silence transitional warnings" for Step 1. **Maintainer judgment call:** accept the documented transitional risk to Step 3, or (cheap hardening) back it with `OSAllocatedUnfairLock`/atomic now. Not a blocker for Step 1's stated contract. + +### #8 — `BluetoothActor.swift:238` — route `broadcastState` through locked storage — ⚠️ CONDITIONAL on #7 +Only relevant if #7 is acted on. Mechanically correct follow-up. Same defer-to-Step-3 disposition. + +### #9 — `BluetoothActor.swift:136` — `queue: nil` ties callbacks to the main thread — ✅ CORRECT, WORTH ACTING ON +Factually right: `CBCentralManager(delegate:queue:)` with `queue: nil` delivers delegate callbacks on the **main queue**. The base used a dedicated `userInitiated` serial queue (`BluetoothManager.swift:38`, passed at line 71). This PR's `queue: nil` is an unflagged behavior/perf regression — high-frequency `didDiscover` callbacks now touch the main thread before hopping to the actor. + +The plan only said to *remove* `BluetoothManager`'s `queue: DispatchQueue` property (item 4); it never specified passing `nil`. Functional correctness is preserved (everything hops to the actor immediately), but the main-thread coupling is avoidable. **Recommendation: restore a dedicated serial queue** in `setupCentralManager()`. Low effort, aligns with the prior design. + +## Root Cause / Synthesis + +The Peripheral-cluster comments trace to a single planning miss: the Step 1 plan asserted `Peripheral` was already `@unchecked Sendable` and could stay untouched, but the base `Peripheral` was a plain `class`. To make the actor's `discoveredPeripheralsSubject.send([Peripheral])` and `nonisolated(unsafe)` publishers compile, the author had to make `Peripheral` Sendable — effectively pulling Step 2 (#17) forward as an `NSLock` + `@unchecked Sendable` stopgap. During that unplanned rework, `peripheralIdentifier` was refactored from stored to computed, silently breaking retrieve-after-invalidation (#1). The compile-correctness comments (#4, #6) are wrong because the code does build; the concurrency comments (#7–#8) flag a real-but-intentionally-deferred shortcut; #9 is a genuine, separate behavior regression. + +## Recommendations + +**Fix before merge (correctness):** +1. Restore a **stored** `peripheralIdentifier: UUID?` on `Peripheral`; seed it in `init` from `peripheral?.identifier` and refresh it in `update(cbPeripheral:)` from `cbPeripheral.identifier`. (Comments #1/#2/#3.) `Sources/ReliaBLE/Models/Peripheral.swift`. This restores `BluetoothActor.refreshPeripherals()` after invalidation. +2. Restore a **dedicated serial queue** for the central manager in `setupCentralManager()` instead of `queue: nil`. (Comment #9.) `Sources/ReliaBLE/BluetoothActor.swift:136`. + +**Cheap quality wins:** +3. Adopt the plan's shim form `Task { @BluetoothActor in … }` and call `handlePeripheralDiscovered`/`handleCentralManagerStateUpdate` synchronously inside actor isolation; this realigns with the plan and may let you delete the region-isolation workaround comment. (Comment #6.) `Sources/ReliaBLE/BluetoothActor.swift:347-360`. +4. Remove the redundant `nonisolated` on `Peripheral.hash(into:)` and fix the `CBPeriphal` typo. (Comments #4 partial, #5.) `Sources/ReliaBLE/Models/Peripheral.swift:151,156`. + +**Maintainer judgment (safe to defer to Step 3, but document the decision):** +5. `currentBluetoothState` is a real (if narrow) data race on a payloaded enum. Either accept the `// TODO: removed in Step 3` transitional risk explicitly, or harden now with `OSAllocatedUnfairLock`/atomic. (Comments #7/#8.) + +**Process:** +6. Reconcile the plan vs. reality: note in the plan/issue that `Peripheral` had to be made Sendable in Step 1 (the "already `@unchecked Sendable`" premise was wrong), and that this is an interim stopgap to be replaced by the audit §B pure-`struct` design in Step 2 (#17). + +## Disposition Table + +| # | File:Line | Copilot claim | Verdict | +|---|-----------|---------------|---------| +| 1 | Peripheral.swift:45 | identifier lost after invalidation | ✅ Correct — fix (regression) | +| 2 | Peripheral.swift:121 | init must seed identifier | ✅ Correct — fix | +| 3 | Peripheral.swift:136 | update must refresh identifier | ✅ Correct — fix | +| 4 | Peripheral.swift:151 | `nonisolated` won't compile | ❌ Wrong rationale — remove as redundant only | +| 5 | Peripheral.swift:158 | typo `CBPeriphal` | ✅ Correct — trivial | +| 6 | BluetoothActor.swift:360 | shim forces non-Sendable cross | ⚠️ Wrong rationale, but adopt (matches plan) | +| 7 | BluetoothActor.swift:98 | `currentBluetoothState` race | ⚠️ Valid in principle; plan defers to Step 3 | +| 8 | BluetoothActor.swift:238 | route through locked storage | ⚠️ Conditional on #7 | +| 9 | BluetoothActor.swift:136 | `queue: nil` → main thread | ✅ Correct — restore dedicated queue | + +## Preventive Measures +- When a plan asserts a precondition ("X is already Sendable"), verify it against the **base branch** before relying on it; a wrong premise here cascaded into unplanned cross-step scope creep and a functional regression. +- Treat AI review comments framed as "won't compile" skeptically when CI/build is green — distinguish *compile claims* (verifiable instantly) from *design suggestions* (judgment). +- For transitional `nonisolated(unsafe)` shortcuts, prefer a payload-safe primitive (atomic/lock) over relying on "single writer + value semantics" when the type is a payloaded enum. From d5bab5660b144d1b51feee968cdb4b01ea7c9063 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Tue, 23 Jun 2026 22:47:22 -0600 Subject: [PATCH 16/27] Added plan and critique for removing Combine --- .../combine-to-asyncstream-2026-06-18.md | 118 ++++++++++++++++++ ...to-asyncstream-plan-critique-2026-06-18.md | 26 ++++ 2 files changed, 144 insertions(+) create mode 100644 docs/plans/combine-to-asyncstream-2026-06-18.md create mode 100644 docs/reviews/combine-to-asyncstream-plan-critique-2026-06-18.md diff --git a/docs/plans/combine-to-asyncstream-2026-06-18.md b/docs/plans/combine-to-asyncstream-2026-06-18.md new file mode 100644 index 0000000..a8c7c6b --- /dev/null +++ b/docs/plans/combine-to-asyncstream-2026-06-18.md @@ -0,0 +1,118 @@ +# Combine → AsyncStream Broadcaster: Plan (Step 3 of 5) +*Issue #12 · 2026-06-18* + +## Goal + +Replace the three `AnyPublisher` event surfaces on `ReliaBLEManager` — `state`, `peripheralDiscoveries`, `discoveredPeripherals` — with per-subscription `AsyncStream`s fed by an in-`BluetoothActor` broadcaster, and remove `Combine` from `Sources/ReliaBLE` and `Sources/ReliaBLEMock` entirely. Update the Demo's `CentralViewModel` to consume via `.task { for await … }`. Fixes **Cluster 2** of the Swift 6 concurrency audit (non-`Sendable` Combine types in the public API). + +This is **Step 3 of 5**. Steps 1 (#13, `@globalActor BluetoothActor`) and 2 (#17, `Peripheral` Sendable struct) are merged. Step 4 (#18) makes `ReliaBLEManager` `Sendable` and collapses `BluetoothManager`; Step 5 is logging/strict-concurrency/DocC polish. + +## Background + +All current state verified at the file:line refs below. + +**Public surface (`ReliaBLEManager.swift`)** — three computed properties forward to `BluetoothManager`: +- `state: AnyPublisher` (:55) +- `peripheralDiscoveries: AnyPublisher` (:80) +- `discoveredPeripherals: AnyPublisher<[Peripheral], Never>` (:85) +- `currentState: BluetoothState` (:60) — **synchronous** accessor, *not* a Combine surface (see Open Questions). +- `import Combine` (:27). + +**Forwarding layer (`BluetoothManager.swift`)** — thin computed getters onto the actor's bridging props: +- `state` → `BluetoothActor.shared.statePublisher` (:72) +- `currentState` → `BluetoothActor.shared.currentBluetoothState` (:79) +- `peripheralDiscoveries` → `discoveryPublisher` (:98), `discoveredPeripherals` → `discoveredPeripheralsPublisher` (:103) +- `import Combine` (:27). `BluetoothState: Sendable` enum (:147). + +**Actor internals (`BluetoothActor.swift`)** — all Combine state is actor-owned, bridged out via `nonisolated(unsafe)`: +- Subjects (:83–85): `stateSubject = CurrentValueSubject(.unknown)`, `discoverySubject = PassthroughSubject`, `discoveredPeripheralsSubject = PassthroughSubject`. +- `nonisolated(unsafe) let` publishers (:96, :99, :102) + `nonisolated(unsafe) var currentBluetoothState = .unknown` (:108). +- `init()` erases subjects → publishers (:113–115). +- `.send(...)` sites: `broadcastState` (:244, also writes `currentBluetoothState` :248), `handlePeripheralDiscovered` (discovery event :287; list :336), `invalidatePeripherals` (:341), `refreshPeripherals` (:359). +- `import Combine` (:27). +- The actor already holds `discoveredPeripherals: [Peripheral]` (the list-replay source) and `cbPeripherals` registry — both stay. + +**Cleanup markers** — 7 `// TODO: removed in Step 3` sites: `BluetoothActor.swift` (:95, :98, :101, :107, :247) and `BluetoothManager.swift` (:71, :77). + +**Element types are already `Sendable`** (Step 2): `Peripheral` (struct), `PeripheralDiscoveryEvent` (struct), `BluetoothState` (enum). No element-type work needed. + +**Stays in place (do NOT remove):** `SendableWrapper: @unchecked Sendable` (`BluetoothActor.swift:30`) — it hops the non-`Sendable` `CBPeripheral` + `[String: Any]` from the delegate queue into the actor. It is **not** a Combine workaround (no Step-3 TODO marker) and was deliberately retained in Step 2. + +**Demo consumption (`Demo/ReliaBLE Demo/.../Central/`)** — single consumer, `CentralViewModel.setupSubscriptions()`: +- `@Observable class CentralViewModel` (CentralViewModel.swift:38) — not `@MainActor`, not `ObservableObject`. `var cancellables = Set()` (:42), `var currentState` (:39). +- `state` → `.receive(on: .main).assign(to: \.currentState)` (:53–57). +- `peripheralDiscoveries` → `.sink { insert DiscoveryEvent into SwiftData }` (:59–68). +- `discoveredPeripherals` → `.sink { sync Device SwiftData models }` (:70–89). +- `import Combine` at CentralViewModel.swift:27 and CentralView.swift:27. +- Wiring: `.onAppear { setDependencies(...) }` → `setupSubscriptions()` (CentralView.swift:142); teardown `.onDisappear { cancellables.removeAll() }` (:144). + +**Tests** — `Tests/ReliaBLETests/ReliaBLEManagerTests.swift` has **zero** references to Combine or the three surfaces; the migration breaks no existing test. + +## Approach + +The broadcaster lives entirely inside `BluetoothActor`. Three `[UUID: AsyncStream.Continuation]` dictionaries replace the three Combine subjects; three `nonisolated` factory methods mint a fresh stream per call; the existing broadcast paths `.yield(...)` into the continuations instead of `subject.send(...)`. + +**Broadcaster state** (replaces the subjects + erased publishers): +```swift +private var stateContinuations: [UUID: AsyncStream.Continuation] = [:] +private var discoveryContinuations: [UUID: AsyncStream.Continuation] = [:] +private var peripheralsContinuations: [UUID: AsyncStream<[Peripheral]>.Continuation] = [:] +// kept: nonisolated(unsafe) var currentBluetoothState — replay source + sync `currentState` backing +``` + +**Stream factories** (internal `nonisolated`, called by the façade): +```swift +nonisolated func stateStream() -> AsyncStream { + AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + let id = UUID() + Task { @BluetoothActor in + continuation.yield(currentBluetoothState) // replay (state only) + stateContinuations[id] = continuation + continuation.onTermination = { _ in + Task { @BluetoothActor in stateContinuations[id] = nil } + } + } + } +} +``` +`discoveredPeripheralsStream()` is identical but replays the current `discoveredPeripherals` array (also `.bufferingNewest(1)`). `peripheralDiscoveriesStream()` omits the replay yield and uses the default unbounded buffer (BLE ad rate is low; switch to `.bufferingNewest(n)` only if back-pressure is ever observed). + +**Atomicity / ordering:** the factory's `Task { @BluetoothActor in … }` body has no `await`, so the replay-yield, registration, and `onTermination` assignment run as one indivisible actor job — they cannot interleave with `broadcastState`. The only residual gap is the window between `AsyncStream` creation and that job starting: an event emitted then is missed by a *new* `peripheralDiscoveries` subscriber (no replay). Accepted for a lightweight advertisements feed; documented, not engineered around. + +**Broadcast sites** (5 `.send` → yields): `broadcastState` loops `stateContinuations` (and still writes `currentBluetoothState`); `handlePeripheralDiscovered` yields the event to `discoveryContinuations` and the updated array to `peripheralsContinuations`; `invalidatePeripherals` / `refreshPeripherals` yield the array to `peripheralsContinuations`. A small private `broadcast(_:to:)` helper keeps the loops DRY — it just iterates `.values` and yields; it never prunes. Dead continuations are removed by their own `onTermination` (a fire-and-forget actor hop, which is safe because yielding to an already-finished continuation is a harmless no-op). + +**Façade:** `BluetoothManager` and `ReliaBLEManager` swap each `AnyPublisher` property for `AsyncStream`, delegating to the actor factory (`BluetoothActor.shared.stateStream()`, etc.). `currentState` and its `nonisolated(unsafe)` snapshot are untouched. `import Combine` leaves all three files. + +**Demo:** `CentralView` replaces the `.onAppear`-installs-sinks / `.onDisappear`-clears-cancellables pattern with three `.task { for await v in reliaBLE. { … } }` blocks (SwiftUI cancels them on disappear). Each loop body hops to the main actor (`await MainActor.run { … }` or an `@MainActor` VM handler) before touching `@Observable` state or SwiftData. `CentralViewModel` drops `import Combine`, `cancellables`, and `setupSubscriptions()`, keeping the conversion logic in handler methods. + +## Work Items + +Edit the library inside-out so the build stays green; the three library files (1–3) must land together if any intermediate build is required. + +1. **`BluetoothActor.swift` — broadcaster core.** Drop `import Combine`, the three subjects (:83–85), the three `nonisolated(unsafe) let …Publisher` (:96–102), and the `init` erasure (:113–115). Add the three continuation dictionaries + three `nonisolated` stream factories. Rewrite the 5 yield sites (`broadcastState` :244, `handlePeripheralDiscovered` :287/:336, `invalidatePeripherals` :341, `refreshPeripherals` :359). **Keep** `currentBluetoothState` (reword its TODO :107 → “retained for sync `currentState` + `stateStream()` replay”), `SendableWrapper` (:30), `forceMock`, and lazy-init. +2. **`BluetoothManager.swift` — façade.** Drop `import Combine`; retype `state` / `peripheralDiscoveries` / `discoveredPeripherals` (:72/:98/:103) to `AsyncStream<…>` delegating to the actor factories; reword/remove the Step-3 TODOs (:71, :77). Leave `currentState` (:79) and the `BluetoothState` enum (:147) unchanged. +3. **`ReliaBLEManager.swift` — public surface.** Drop `import Combine` (:27); retype the three properties (:55/:80/:85) to `AsyncStream<…>`; refresh the doc comments (s/Publisher/stream). Leave `currentState` (:60) unchanged. *(Breaking API change — intentional, pre-1.0.)* +4. **Demo — `CentralViewModel.swift` + `CentralView.swift`.** Remove `import Combine`, `cancellables`, and `setupSubscriptions()`; move the existing SwiftData/assignment logic into `@MainActor` handler methods (`updateState`, `insertDiscovery`, `syncDevices`). In `CentralView`, replace the onAppear/onDisappear wiring with three `.task` loops feeding those handlers; keep `setDependencies` for context/manager injection. +5. **Tests — `ReliaBLEManagerTests.swift`.** Existing tests are unaffected (they touch none of the surfaces). Add one test (via `ReliaBLEMock`): two concurrent `stateStream()` subscribers both replay the current state and receive a later broadcast; `peripheralDiscoveriesStream()` does not replay; each property access returns a distinct stream. +6. **DocC — `Documentation.docc/GettingStarted.md` (+ `Documentation.md`).** Replace the `.sink` / `.store(in:)` examples with `.task { for await … }`. Document prominently: fresh stream per access, multi-subscriber by design, replay for `state` / `discoveredPeripherals`, no replay for `peripheralDiscoveries`. +7. **Verify.** `swift build` + `swift test`; grep confirms zero `import Combine` in `Sources/ReliaBLE*`; Demo scheme builds; `currentState` still synchronous; `forceMock` / lazy-init / `SendableWrapper` intact. + +## Decisions (resolved at planning) + +1. **`currentState` stays synchronous; `currentBluetoothState` stays `nonisolated(unsafe)`.** It is not a Combine surface. Step 3 removes only Combine machinery; the snapshot now backs both `currentState` and `stateStream()` replay. Its TODO is reworded, not actioned. Revisiting `currentState`'s isolation belongs to Step 4 (#18). +2. **Buffering:** `.bufferingNewest(1)` for the two replay/snapshot surfaces — exactly 1, latest-wins; a larger buffer would hand a slow consumer stale intermediate snapshots. Unbounded (default) for the discoveries feed. +3. **No typed wrapper.** Return plain `AsyncStream`; defer a `BluetoothEventStream` façade unless an ergonomic need appears (the issue's open question). +4. **`peripheralDiscoveries` registration window** (an event lost between stream creation and continuation registration) is accepted and documented, not engineered around. + +## Open Questions + +None blocking. Two items to confirm during implementation: +- Whether a subscriber that opens `stateStream()` exactly as `broadcastState` fires sees a duplicated value — `.bufferingNewest(1)` collapses this to the latest, so it is benign; assert in the multi-subscriber test. +- Demo only: keep `setDependencies` vs. fold manager/context capture directly into the `.task` blocks — cosmetic, decide in code. + +## References + +- Issue #12 (this); parent #10; depends on #13 (Step 1, merged) and #17 (Step 2, merged). +- Investigation report: `docs/investigations/swift6-concurrency-audit-2026-05-13.md` — Findings §1/§2, Cluster 2, Recommendations §A (AsyncStream broadcaster). +- Step 1 plan: `docs/plans/bluetooth-actor-migration-2026-06-08.md`; Step 2 plan: `docs/plans/peripheral-sendable-struct-2026-06-13.md`. +- Key files: `Sources/ReliaBLE/BluetoothActor.swift`, `BluetoothManager.swift`, `ReliaBLEManager.swift`; `Sources/ReliaBLE/Documentation.docc/`; `Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift`. diff --git a/docs/reviews/combine-to-asyncstream-plan-critique-2026-06-18.md b/docs/reviews/combine-to-asyncstream-plan-critique-2026-06-18.md new file mode 100644 index 0000000..d62844e --- /dev/null +++ b/docs/reviews/combine-to-asyncstream-plan-critique-2026-06-18.md @@ -0,0 +1,26 @@ +# Critique: Combine → AsyncStream Plan (2026-06-18) + +**Scope**: Only the three named seams in `BluetoothActor.swift`, `BluetoothManager.swift`, `ReliaBLEManager.swift` (plan lines 83-108, 244, 287, 336, 341, 359). + +## 1. Top 3 Under-specified Seams + +- `stateStream()` replay of `currentBluetoothState` (plan:59) — no specification of what happens if `currentBluetoothState` is mutated between the `yield` and the first downstream `for await` consumption. +- `peripheralDiscoveriesStream()` registration window (plan:68) — the documented “event lost” gap is accepted but has no test or logging hook to surface it in production. +- `broadcast(_:to:)` helper (plan:78) — signature, error handling, and whether it removes dead continuations are unspecified. + +## 2. Contradictions / Missing Dependencies + +- Plan says “Drop `import Combine`” from all three files (Work Items 1-3), yet `BluetoothActor.swift:30` retains `SendableWrapper` which is **not** a Combine type; the TODO markers at :95/:98/:101/:107 are the only Combine-specific lines. Removing the import is safe, but the plan’s wording implies more Combine removal than actually exists. +- `currentBluetoothState` TODO reword (plan:107) is listed under “keep” but the Work Items still say “reword its TODO” — a minor internal inconsistency. + +## 3. Risk of Over-planning + +- Work Item 5 (new multi-subscriber test) and Work Item 6 (DocC) are full implementation tasks, not plan-level decisions. They can be cut or deferred without changing the broadcaster design. +- The “Open Questions” section is empty; the two cosmetic Demo items do not affect library order. + +## 4. Questions That Change Implementation Order + +- Must the `onTermination` cleanup `Task` be fire-and-forget, or should it be awaited inside a detached task to guarantee removal before the next broadcast? +- Is `.bufferingNewest(1)` for `state`/`discoveredPeripherals` streams required to be exactly 1, or can it be a larger bounded buffer without affecting the “latest value” replay contract? + +**Broadcaster actor-isolation sanity check**: The `Task { @BluetoothActor in … }` body contains only synchronous operations (`yield`, dictionary write, `onTermination` setter). No suspension points exist inside the closure, so the three statements execute as one indivisible actor job. The documented window between `AsyncStream` creation and job start remains the only gap; `.bufferingNewest(1)` + `nonisolated(unsafe) currentBluetoothState` correctly backs both replay and the synchronous `currentState` accessor. No contradictions found in the named seams. From f2439f503717e352002c9fa6490f7990701c4422 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Tue, 23 Jun 2026 22:55:52 -0600 Subject: [PATCH 17/27] Replace Combine event surfaces with AsyncStream broadcaster (Step 3 of 5, #10) Replace the three AnyPublisher surfaces on ReliaBLEManager (state, peripheralDiscoveries, discoveredPeripherals) with per-subscription AsyncStreams fed by an in-BluetoothActor broadcaster, and remove Combine from Sources/ReliaBLE and the Demo. - BluetoothActor: three [UUID: AsyncStream.Continuation] dictionaries replace the Combine subjects/publishers; nonisolated stream factories mint a fresh stream per call and register on an actor hop. state/discoveredPeripherals replay their latest value (.bufferingNewest(1)); peripheralDiscoveries does not replay (unbounded). currentBluetoothState retained as the sync currentState backing + state replay source. - BluetoothManager/ReliaBLEManager: retype the three properties to AsyncStream. - Demo: consume via .task { for await } loops feeding @MainActor handlers; drop cancellables/setupSubscriptions. - Tests: add broadcaster coverage (multi-subscriber replay, fan-out, no-replay). - DocC: replace .sink examples with for await; document streaming semantics. Co-Authored-By: Claude Opus 4.8 --- .../ReliaBLE Demo/Central/CentralView.swift | 17 ++- .../Central/CentralViewModel.swift | 82 +++++----- Sources/ReliaBLE/BluetoothActor.swift | 144 +++++++++++++----- Sources/ReliaBLE/BluetoothManager.swift | 30 ++-- .../Documentation.docc/GettingStarted.md | 42 +++-- Sources/ReliaBLE/ReliaBLEManager.swift | 33 ++-- .../ReliaBLETests/ReliaBLEManagerTests.swift | 71 +++++++++ 7 files changed, 290 insertions(+), 129 deletions(-) diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift index bcb936e..f37dc93 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift @@ -24,7 +24,6 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import Combine import CoreBluetooth import SwiftUI import SwiftData @@ -98,8 +97,20 @@ struct CentralView: View { .onAppear { viewModel.setDependencies(modelContext: modelContext, reliaBLE: reliaBLE) } - .onDisappear { - viewModel.cancellables.removeAll() + .task { + for await state in reliaBLE.state { + viewModel.updateState(state) + } + } + .task { + for await discoveryEvent in reliaBLE.peripheralDiscoveries { + viewModel.insertDiscovery(discoveryEvent, into: modelContext) + } + } + .task { + for await peripherals in reliaBLE.discoveredPeripherals { + viewModel.syncDevices(peripherals, into: modelContext) + } } } diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift index 175702a..b7b7a4f 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift @@ -24,7 +24,6 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import Combine import CoreBluetooth import SwiftData import SwiftUI @@ -35,60 +34,53 @@ import ReliaBLE var currentState: BluetoothState = .unknown var servicesInput = "" - var cancellables = Set() - private var modelContext: ModelContext? private var reliaBLE: ReliaBLEManager? func setDependencies(modelContext: ModelContext, reliaBLE: ReliaBLEManager) { self.modelContext = modelContext self.reliaBLE = reliaBLE - - setupSubscriptions() } - private func setupSubscriptions() { - guard let reliaBLE = reliaBLE, let modelContext = modelContext else { return } - - reliaBLE.state - .receive(on: DispatchQueue.main) - .assign(to: \.currentState, on: self) - .store(in: &cancellables) - - reliaBLE.peripheralDiscoveries - .receive(on: DispatchQueue.main) - .sink { discoveryEvent in - let event = DiscoveryEvent( - peripheralIdentifier: discoveryEvent.id.uuidString, - name: discoveryEvent.name ?? "Unknown", - rssi: discoveryEvent.rssi, - timestamp: Date() - ) - modelContext.insert(event) - try? modelContext.save() - } - .store(in: &cancellables) - - reliaBLE.discoveredPeripherals - .receive(on: DispatchQueue.main) - .sink { peripherals in - do { - let allDevices = try modelContext.fetch(FetchDescriptor()) - for peripheral in peripherals { - if let existingDevice = allDevices.first(where: { $0.id == peripheral.id }) { - existingDevice.name = peripheral.name - existingDevice.lastSeen = peripheral.lastSeen - } else { - let newDevice = Device(id: peripheral.id, name: peripheral.name, lastSeen: Date()) - modelContext.insert(newDevice) - } - } - try modelContext.save() - } catch { - print("Error fetching devices: \(error)") + // MARK: - Stream Handlers + // + // Fed by the `.task { for await … }` loops in `CentralView`. Each handler runs on the main + // actor before touching `@Observable` state or SwiftData. + + @MainActor + func updateState(_ state: BluetoothState) { + currentState = state + } + + @MainActor + func insertDiscovery(_ discoveryEvent: PeripheralDiscoveryEvent, into modelContext: ModelContext) { + let event = DiscoveryEvent( + peripheralIdentifier: discoveryEvent.id.uuidString, + name: discoveryEvent.name ?? "Unknown", + rssi: discoveryEvent.rssi, + timestamp: Date() + ) + modelContext.insert(event) + try? modelContext.save() + } + + @MainActor + func syncDevices(_ peripherals: [Peripheral], into modelContext: ModelContext) { + do { + let allDevices = try modelContext.fetch(FetchDescriptor()) + for peripheral in peripherals { + if let existingDevice = allDevices.first(where: { $0.id == peripheral.id }) { + existingDevice.name = peripheral.name + existingDevice.lastSeen = peripheral.lastSeen + } else { + let newDevice = Device(id: peripheral.id, name: peripheral.name, lastSeen: Date()) + modelContext.insert(newDevice) } } - .store(in: &cancellables) + try modelContext.save() + } catch { + print("Error fetching devices: \(error)") + } } func authorizeBluetooth() { diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index f1ca3c9..19f6151 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -24,7 +24,6 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import Combine import CoreBluetooth import Foundation @@ -50,8 +49,8 @@ private struct DiscoveryPayload: @unchecked Sendable { /// Process-wide global actor that serializes all CoreBluetooth interactions. /// -/// All mutable BLE state—`CBCentralManager`, Combine subjects, and discovered -/// peripherals—are owned exclusively by this actor. Two `ReliaBLEManager` instances +/// All mutable BLE state—`CBCentralManager`, per-subscriber `AsyncStream` continuations, and +/// discovered peripherals—are owned exclusively by this actor. Two `ReliaBLEManager` instances /// share the same isolation domain; this is acceptable because CoreBluetooth already /// enforces a single central manager per process. /// @@ -82,40 +81,114 @@ public actor BluetoothActor { /// `id`; operations that need the live peripheral look it up here. private var cbPeripherals: [String: CBPeripheral] = [:] - // Combine subjects — written only from actor-isolated context. - let stateSubject = CurrentValueSubject(.unknown) - let discoverySubject = PassthroughSubject() - let discoveredPeripheralsSubject = PassthroughSubject<[Peripheral], Never>() - - // MARK: - nonisolated(unsafe) Bridging Properties - // - // Publishers are extracted once in `init` and never reassigned — safe for concurrent reads. - // `currentBluetoothState` is written only by the serial actor executor via - // `broadcastState(_:)` — safe for concurrent reads (value semantics, no partial writes). + // MARK: - AsyncStream Broadcaster State // - // TODO: All removed in Step 3 when AnyPublisher is replaced by AsyncStream. + // One continuation per active subscriber, keyed by a per-subscription UUID. Mutated only on + // the actor's serial executor: the stream factories register on a `@BluetoothActor` hop, the + // broadcast sites iterate to `yield`, and each `onTermination` handler prunes its own entry. - /// Publisher for the real-time Bluetooth state. **TODO: removed in Step 3.** - nonisolated(unsafe) let statePublisher: AnyPublisher + private var stateContinuations: [UUID: AsyncStream.Continuation] = [:] + private var discoveryContinuations: [UUID: AsyncStream.Continuation] = [:] + private var peripheralsContinuations: [UUID: AsyncStream<[Peripheral]>.Continuation] = [:] - /// Publisher for peripheral discovery events. **TODO: removed in Step 3.** - nonisolated(unsafe) let discoveryPublisher: AnyPublisher - - /// Publisher for the current list of discovered peripherals. **TODO: removed in Step 3.** - nonisolated(unsafe) let discoveredPeripheralsPublisher: AnyPublisher<[Peripheral], Never> + // MARK: - nonisolated(unsafe) Bridging Property /// Synchronous snapshot of the current Bluetooth state. /// - /// Written only from `broadcastState(_:)` on the actor's serial executor. - /// **TODO: removed in Step 3.** + /// Written only from `broadcastState(_:)` on the actor's serial executor — safe for concurrent + /// reads (value semantics, no partial writes). Retained as the backing store for the + /// synchronous `currentState` accessor and as the replay value handed to each new + /// `stateStream()` subscriber. nonisolated(unsafe) var currentBluetoothState: BluetoothState = .unknown // MARK: - Initialization - private init() { - statePublisher = stateSubject.eraseToAnyPublisher() - discoveryPublisher = discoverySubject.eraseToAnyPublisher() - discoveredPeripheralsPublisher = discoveredPeripheralsSubject.eraseToAnyPublisher() + private init() {} + + // MARK: - Event Streams + + /// Returns a fresh `AsyncStream` of Bluetooth state changes for a single subscriber. + /// + /// Each call mints an independent stream; multiple subscribers are supported by design. The + /// current state is replayed as the first element (`.bufferingNewest(1)`, latest-wins), so a + /// new subscriber always observes the current state without waiting for the next broadcast. + nonisolated func stateStream() -> AsyncStream { + AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + Task { await BluetoothActor.shared.register(stateContinuation: continuation) } + } + } + + /// Returns a fresh `AsyncStream` of peripheral discovery events for a single subscriber. + /// + /// Unlike ``stateStream()`` and ``discoveredPeripheralsStream()`` this feed does **not** replay + /// a value on subscription; a subscriber only receives advertisements observed after it + /// registers. An advertisement that arrives in the narrow window between stream creation and + /// continuation registration is missed — accepted for a lightweight advertisements feed. + nonisolated func peripheralDiscoveriesStream() -> AsyncStream { + AsyncStream { continuation in + Task { await BluetoothActor.shared.register(discoveryContinuation: continuation) } + } + } + + /// Returns a fresh `AsyncStream` of the current discovered-peripherals list for a single + /// subscriber. + /// + /// The current list is replayed as the first element (`.bufferingNewest(1)`, latest-wins), so + /// a new subscriber immediately observes the peripherals already discovered. + nonisolated func discoveredPeripheralsStream() -> AsyncStream<[Peripheral]> { + AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + Task { await BluetoothActor.shared.register(peripheralsContinuation: continuation) } + } + } + + // MARK: - Continuation Registration + // + // Registration runs as a single, indivisible actor job: the replay-yield, dictionary insert, + // and `onTermination` assignment cannot interleave with a broadcast. The only residual gap is + // the window between `AsyncStream` creation and this job starting — an event emitted then is + // missed by a *new* (replay-less) `peripheralDiscoveries` subscriber. Accepted and documented. + + private func register(stateContinuation continuation: AsyncStream.Continuation) { + let id = UUID() + continuation.yield(currentBluetoothState) + stateContinuations[id] = continuation + continuation.onTermination = { _ in + Task { await BluetoothActor.shared.removeStateContinuation(id) } + } + } + + private func register(discoveryContinuation continuation: AsyncStream.Continuation) { + let id = UUID() + discoveryContinuations[id] = continuation + continuation.onTermination = { _ in + Task { await BluetoothActor.shared.removeDiscoveryContinuation(id) } + } + } + + private func register(peripheralsContinuation continuation: AsyncStream<[Peripheral]>.Continuation) { + let id = UUID() + continuation.yield(discoveredPeripherals) + peripheralsContinuations[id] = continuation + continuation.onTermination = { _ in + Task { await BluetoothActor.shared.removePeripheralsContinuation(id) } + } + } + + private func removeStateContinuation(_ id: UUID) { stateContinuations[id] = nil } + private func removeDiscoveryContinuation(_ id: UUID) { discoveryContinuations[id] = nil } + private func removePeripheralsContinuation(_ id: UUID) { peripheralsContinuations[id] = nil } + + /// Yields a value to every registered continuation in `continuations`. + /// + /// Yielding to an already-finished continuation is a harmless no-op, so this never prunes — + /// dead continuations remove themselves via their `onTermination` handler. + private func broadcast( + _ value: Element, + to continuations: [UUID: AsyncStream.Continuation] + ) { + for continuation in continuations.values { + continuation.yield(value) + } } // MARK: - Configuration @@ -245,10 +318,10 @@ public actor BluetoothActor { } private func broadcastState(_ state: BluetoothState) { - stateSubject.send(state) - // nonisolated(unsafe) write — safe: written only from the actor's serial executor. - // TODO: removed in Step 3 + // nonisolated(unsafe) write — safe: written only from the actor's serial executor. Also + // serves as the replay value handed to each new `stateStream()` subscriber. currentBluetoothState = state + broadcast(state, to: stateContinuations) } // MARK: - Delegate Entry Points (called by BluetoothDelegateShim) @@ -286,8 +359,9 @@ public actor BluetoothActor { // Emit lightweight discovery feed. // TODO: Implement verbose log level - discoverySubject.send( - PeripheralDiscoveryEvent(cbPeripheral: cbPeripheral, advertisement: advertisement, rssi: rssi) + broadcast( + PeripheralDiscoveryEvent(cbPeripheral: cbPeripheral, advertisement: advertisement, rssi: rssi), + to: discoveryContinuations ) // TODO: FR-8.5: Unique Identifier from Manufacturing Data — connect to id once implemented @@ -339,13 +413,13 @@ public actor BluetoothActor { // Stash the live reference under the resolved id. Never escapes the actor. cbPeripherals[resolvedId] = cbPeripheral - discoveredPeripheralsSubject.send(discoveredPeripherals) + broadcast(discoveredPeripherals, to: peripheralsContinuations) } private func invalidatePeripherals() { // The value snapshots hold no CoreBluetooth reference to clear; drop the live registry instead. cbPeripherals.removeAll() - discoveredPeripheralsSubject.send(discoveredPeripherals) + broadcast(discoveredPeripherals, to: peripheralsContinuations) log?.debug("Invalidated all peripheral references") } @@ -364,7 +438,7 @@ public actor BluetoothActor { cbPeripherals[p.id] = cbPeripheral } } - discoveredPeripheralsSubject.send(discoveredPeripherals) + broadcast(discoveredPeripherals, to: peripheralsContinuations) log?.debug("Refreshed \(retrieved.count) peripherals from CBCentralManager") } diff --git a/Sources/ReliaBLE/BluetoothManager.swift b/Sources/ReliaBLE/BluetoothManager.swift index e8f766c..c8a9e84 100644 --- a/Sources/ReliaBLE/BluetoothManager.swift +++ b/Sources/ReliaBLE/BluetoothManager.swift @@ -24,12 +24,11 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import Combine import CoreBluetooth import Foundation /// Internal intermediary that forwards BLE operations to ``BluetoothActor`` and exposes -/// synchronous `AnyPublisher` properties for consumption by ``ReliaBLEManager``. +/// synchronous `AsyncStream` properties for consumption by ``ReliaBLEManager``. /// /// This class is removed in Step 4 when `ReliaBLEManager` collapses the indirection and /// becomes `nonisolated Sendable`. @@ -67,14 +66,14 @@ class BluetoothManager { // MARK: - State - /// Publisher for the real-time state of the underlying Core Bluetooth system. - /// Reads the `nonisolated(unsafe)` publisher on ``BluetoothActor``. TODO: removed in Step 3. - var state: AnyPublisher { - BluetoothActor.shared.statePublisher + /// A fresh `AsyncStream` of real-time state changes of the underlying Core Bluetooth system. + /// Each access mints an independent stream via the ``BluetoothActor`` broadcaster. + var state: AsyncStream { + BluetoothActor.shared.stateStream() } /// Synchronous access to the current state of the underlying Core Bluetooth system. - /// Reads the `nonisolated(unsafe)` property on ``BluetoothActor``. TODO: removed in Step 3. + /// Reads the `nonisolated(unsafe)` snapshot on ``BluetoothActor``. var currentState: BluetoothState { BluetoothActor.shared.currentBluetoothState } @@ -92,16 +91,17 @@ class BluetoothManager { // MARK: - Scanning - /// Publisher that emits peripheral discovery events during scanning. It is meant to be a - /// lightweight advertisements feed for cases where the integrating app needs to process - /// individual advertisements. - var peripheralDiscoveries: AnyPublisher { - BluetoothActor.shared.discoveryPublisher + /// A fresh `AsyncStream` that emits peripheral discovery events during scanning. It is meant to + /// be a lightweight advertisements feed for cases where the integrating app needs to process + /// individual advertisements. This stream does not replay; subscribe before scanning. + var peripheralDiscoveries: AsyncStream { + BluetoothActor.shared.peripheralDiscoveriesStream() } - /// Publisher that emits the current list of discovered peripherals. - var discoveredPeripherals: AnyPublisher<[Peripheral], Never> { - BluetoothActor.shared.discoveredPeripheralsPublisher + /// A fresh `AsyncStream` that emits the current list of discovered peripherals, replaying the + /// latest list on subscription. + var discoveredPeripherals: AsyncStream<[Peripheral]> { + BluetoothActor.shared.discoveredPeripheralsStream() } /// Starts (or restarts) scanning for peripherals. If scanning is already in progress, diff --git a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md index 836e772..fe6baca 100644 --- a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md +++ b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md @@ -52,23 +52,22 @@ iOS requires permission from the user for BLE access. To set this up in your pro } ``` -3. (Optional) Monitor Bluetooth state changes by subscribing to the ``ReliaBLEManager/state`` publisher: +3. (Optional) Monitor Bluetooth state changes by iterating the ``ReliaBLEManager/state`` stream. It is an `AsyncStream`, so consume it with `for await` — typically inside a SwiftUI `.task { … }`, which cancels the loop when the view disappears: ```swift - bleManager.state - .sink { state in - switch state { - case .ready: - // Bluetooth is ready to use - case .unauthorized(let authStatus): - // Handle unauthorized state - case .poweredOff: - // Prompt user to enable Bluetooth - default: - break - } + for await state in bleManager.state { + switch state { + case .ready: + // Bluetooth is ready to use + case .unauthorized(let authStatus): + // Handle unauthorized state + case .poweredOff: + // Prompt user to enable Bluetooth + default: + break } - .store(in: &cancellables) + } ``` + The current state is replayed as the stream's first element, so a new subscriber immediately observes the latest state. Note: The authorization prompt will only appear once. It is safe to call `ReliaBLEManager.authorizeBluetooth()` multiple times. If the user already granted permission it will be a no-op. If the user denies permission, they'll need to enable it manually through the Settings app. @@ -117,7 +116,7 @@ if bleManager.currentState == .ready { } ``` -You can monitor the ``ReliaBLEManager/state`` publisher (as shown in the Authorizing Bluetooth section) to ensure Bluetooth is in the `.ready` state before calling `startScanning()`. Scanning will continue until you explicitly call `stopScanning()` or if Bluetooth becomes unavailable. +You can monitor the ``ReliaBLEManager/state`` stream (as shown in the Authorizing Bluetooth section) to ensure Bluetooth is in the `.ready` state before calling `startScanning()`. Scanning will continue until you explicitly call `stopScanning()` or if Bluetooth becomes unavailable. ## Observing Discovered Peripherals @@ -126,17 +125,16 @@ While scanning, ReliaBLE surfaces results two ways: - ``ReliaBLEManager/peripheralDiscoveries`` emits a lightweight ``PeripheralDiscoveryEvent`` for every advertisement received — useful when you need to process individual advertisement packets. - ``ReliaBLEManager/discoveredPeripherals`` emits the current de-duplicated list of ``Peripheral`` values each time it changes. +Both are `AsyncStream`s. Each property access returns a *fresh, independent* stream, so multiple subscribers are supported by design — consume each with `for await`, typically inside a SwiftUI `.task { … }` (which cancels the loop automatically when the view disappears). ``ReliaBLEManager/state`` and ``ReliaBLEManager/discoveredPeripherals`` replay their latest value to every new subscriber; ``ReliaBLEManager/peripheralDiscoveries`` does **not** replay, so subscribe before you start scanning to avoid missing early advertisements. + A ``Peripheral`` is an immutable, `Sendable` value snapshot: it carries the peripheral's ``Peripheral/id``, ``Peripheral/name``, ``Peripheral/rssi``, ``Peripheral/lastSeen``, and a strongly-typed ``AdvertisementData`` rather than a raw `[String: Any]` dictionary. Because it is a value type, it is safe to hand directly to your UI. ```swift -bleManager.discoveredPeripherals - .receive(on: DispatchQueue.main) - .sink { peripherals in - for peripheral in peripherals { - print(peripheral.name ?? peripheral.id, peripheral.advertisement?.serviceUUIDs ?? []) - } +for await peripherals in bleManager.discoveredPeripherals { + for peripheral in peripherals { + print(peripheral.name ?? peripheral.id, peripheral.advertisement?.serviceUUIDs ?? []) } - .store(in: &cancellables) +} ``` If your app already knows a peripheral's identity ahead of time — for example, a wearable bound to the user's account — you can construct a ``Peripheral`` directly with ``Peripheral/init(id:)``. Such a snapshot has no ``Peripheral/advertisement`` until ReliaBLE matches it against the corresponding device during discovery. diff --git a/Sources/ReliaBLE/ReliaBLEManager.swift b/Sources/ReliaBLE/ReliaBLEManager.swift index 9a7a662..63d6bab 100644 --- a/Sources/ReliaBLE/ReliaBLEManager.swift +++ b/Sources/ReliaBLE/ReliaBLEManager.swift @@ -24,7 +24,6 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import Combine import CoreBluetooth import Foundation @@ -55,8 +54,17 @@ public class ReliaBLEManager { // MARK: - State - /// Publisher for the real-time state of the underlying Core Bluetooth system. - public var state: AnyPublisher { + /// A multi-subscriber `AsyncStream` of real-time state changes of the underlying Core Bluetooth + /// system. Each property access returns a fresh, independent stream; the current state is + /// replayed as the first element, so a new subscriber immediately observes the latest state. + /// + /// Consume it with `for await`: + /// ```swift + /// for await state in bleManager.state { + /// // react to state + /// } + /// ``` + public var state: AsyncStream { bluetoothManager.state } @@ -75,14 +83,21 @@ public class ReliaBLEManager { // MARK: - Scanning - /// Publisher that emits peripheral discovery events during scanning. It is meant to be a lightweight - /// advertisements feed for cases where the integrating app needs to process individual advertisements. - public var peripheralDiscoveries: AnyPublisher { + /// A multi-subscriber `AsyncStream` that emits peripheral discovery events during scanning. It + /// is meant to be a lightweight advertisements feed for cases where the integrating app needs to + /// process individual advertisements. + /// + /// Each property access returns a fresh, independent stream. Unlike ``state`` and + /// ``discoveredPeripherals`` this stream does **not** replay a value on subscription — subscribe + /// before you start scanning to avoid missing early advertisements. + public var peripheralDiscoveries: AsyncStream { bluetoothManager.peripheralDiscoveries } - - /// Publisher that emits the current list of discovered peripherals. - public var discoveredPeripherals: AnyPublisher<[Peripheral], Never> { + + /// A multi-subscriber `AsyncStream` that emits the current de-duplicated list of discovered + /// peripherals each time it changes. Each property access returns a fresh, independent stream; + /// the current list is replayed as the first element on subscription. + public var discoveredPeripherals: AsyncStream<[Peripheral]> { bluetoothManager.discoveredPeripherals } diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 1dce403..905dff1 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -56,3 +56,74 @@ import Testing #expect(false, "Expected PeripheralError.notFound, got \(error)") } } + +// MARK: - Event Stream Broadcaster + +@Test func stateStreamReplaysToConcurrentSubscribers() async throws { + let manager = ReliaBLEManager() + + // Two independent streams from two separate property accesses. + var subscriberA = manager.state.makeAsyncIterator() + var subscriberB = manager.state.makeAsyncIterator() + + // Each subscriber replays the current state as its first element. A shared single stream + // could not replay to both, so independent replay proves each access mints a distinct stream. + let replayA = await subscriberA.next() + let replayB = await subscriberB.next() + + #expect(replayA != nil) + #expect(replayB != nil) +} + +@Test func stateBroadcastReachesAllSubscribers() async throws { + let manager = ReliaBLEManager() + + var subscriberA = manager.state.makeAsyncIterator() + var subscriberB = manager.state.makeAsyncIterator() + + // Drain the replayed element. Awaiting it also guarantees both continuations are registered + // (the replay is yielded during registration), so the broadcast below cannot be missed. + _ = await subscriberA.next() + _ = await subscriberB.next() + + // Force a state broadcast through the real actor path; both live subscribers receive it. + await BluetoothActor.shared.updateState() + + let broadcastA = await subscriberA.next() + let broadcastB = await subscriberB.next() + + #expect(broadcastA != nil) + #expect(broadcastB != nil) +} + +@Test func peripheralDiscoveriesDoesNotReplay() async throws { + let manager = ReliaBLEManager() + + // No scanning has occurred and the discoveries feed does not replay, so no event should + // arrive within a short grace period. + let event = await firstEvent(from: manager.peripheralDiscoveries, withinNanoseconds: 200_000_000) + + #expect(event == nil) +} + +/// Returns the first event from `stream`, or `nil` if none arrives within `nanoseconds`. +private func firstEvent( + from stream: AsyncStream, + withinNanoseconds nanoseconds: UInt64 +) async -> PeripheralDiscoveryEvent? { + await withTaskGroup(of: PeripheralDiscoveryEvent?.self) { group in + group.addTask { + for await event in stream { + return event + } + return nil + } + group.addTask { + try? await Task.sleep(nanoseconds: nanoseconds) + return nil + } + let first = await group.next() ?? nil + group.cancelAll() + return first + } +} From a683a48aba355601afaeaec8205955e6af9c15eb Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Tue, 23 Jun 2026 10:11:30 -0600 Subject: [PATCH 18/27] Updated agent instruction pointer for Demo app --- {agents => .agents}/skills/xcodebuildmcp/SKILL.md | 0 {Demo/.xcodebuildmcp => .xcodebuildmcp}/config.yaml | 0 AGENTS.md | 8 ++++++++ 3 files changed, 8 insertions(+) rename {agents => .agents}/skills/xcodebuildmcp/SKILL.md (100%) rename {Demo/.xcodebuildmcp => .xcodebuildmcp}/config.yaml (100%) diff --git a/agents/skills/xcodebuildmcp/SKILL.md b/.agents/skills/xcodebuildmcp/SKILL.md similarity index 100% rename from agents/skills/xcodebuildmcp/SKILL.md rename to .agents/skills/xcodebuildmcp/SKILL.md diff --git a/Demo/.xcodebuildmcp/config.yaml b/.xcodebuildmcp/config.yaml similarity index 100% rename from Demo/.xcodebuildmcp/config.yaml rename to .xcodebuildmcp/config.yaml diff --git a/AGENTS.md b/AGENTS.md index 9e263b2..101af68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,14 @@ Two related projects share this repo: Open `ReliaBLE.xcworkspace` at the root to work on both together. +### Working on the Demo app + +The Demo is a **separate project** with its own conventions and build tooling, kept out of the library's context on purpose: + +- Before performing ANY Demo build/run/test task, **read `Demo/CLAUDE.md` first** — it documents the Demo's conventions and its required build tooling (XcodeBuildMCP, not raw `xcodebuild`). +- For substantial Demo work, **delegate to a sub-agent** and instruct it to read `Demo/CLAUDE.md` first. This keeps Demo conventions in an isolated context so they don't pollute the main library session. (A sub-agent does not auto-load `Demo/CLAUDE.md` — tell it to read that file explicitly.) +- Do not carry Demo patterns back into the library. + ## Build, test ```sh From 66f77a66a1f3fd12622394f9d151ab38af79fa98 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 27 Jun 2026 11:17:31 -0600 Subject: [PATCH 19/27] Collapse BluetoothManager into ReliaBLEManager (Step 4 of 5, #10) dropping the indirection layer now that the actor work is done directly. Move BluetoothState/AuthorizationError types and the actor-isolated state snapshot onto ReliaBLEManager, delete BluetoothManager.swift, and update docs/tests to match the new public surface. --- Sources/ReliaBLE/BluetoothActor.swift | 24 +- Sources/ReliaBLE/BluetoothManager.swift | 216 ------------------ .../Documentation.docc/Documentation.md | 2 +- .../Documentation.docc/GettingStarted.md | 4 +- .../ReliaBLE/Models/AdvertisementData.swift | 4 +- Sources/ReliaBLE/Models/Peripheral.swift | 12 +- Sources/ReliaBLE/Models/PeripheralError.swift | 2 +- Sources/ReliaBLE/ReliaBLEManager.swift | 124 ++++++++-- .../ReliaBLETests/ReliaBLEManagerTests.swift | 23 +- .../manager-sendable-collapse-2026-06-23.md | 72 ++++++ ...dable-collapse-plan-critique-2026-06-23.md | 43 ++++ 11 files changed, 264 insertions(+), 262 deletions(-) delete mode 100644 Sources/ReliaBLE/BluetoothManager.swift create mode 100644 docs/plans/manager-sendable-collapse-2026-06-23.md create mode 100644 docs/reviews/manager-sendable-collapse-plan-critique-2026-06-23.md diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index 19f6151..ec10a28 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -58,10 +58,9 @@ private struct DiscoveryPayload: @unchecked Sendable { /// actor's isolation via `Task { @BluetoothActor in … }` inside the nonisolated /// ``BluetoothDelegateShim``. @globalActor -public actor BluetoothActor { - +actor BluetoothActor { /// The process-lifetime shared instance. - public static let shared = BluetoothActor() + static let shared = BluetoothActor() // MARK: - Actor-Isolated State @@ -69,6 +68,9 @@ public actor BluetoothActor { var centralManager: CBCentralManager? private var delegateShim: BluetoothDelegateShim? + + /// The current Bluetooth state. + var currentBluetoothState: BluetoothState = .unknown var log: LoggingService? @@ -91,16 +93,6 @@ public actor BluetoothActor { private var discoveryContinuations: [UUID: AsyncStream.Continuation] = [:] private var peripheralsContinuations: [UUID: AsyncStream<[Peripheral]>.Continuation] = [:] - // MARK: - nonisolated(unsafe) Bridging Property - - /// Synchronous snapshot of the current Bluetooth state. - /// - /// Written only from `broadcastState(_:)` on the actor's serial executor — safe for concurrent - /// reads (value semantics, no partial writes). Retained as the backing store for the - /// synchronous `currentState` accessor and as the replay value handed to each new - /// `stateStream()` subscriber. - nonisolated(unsafe) var currentBluetoothState: BluetoothState = .unknown - // MARK: - Initialization private init() {} @@ -199,7 +191,7 @@ public actor BluetoothActor { /// Configures the actor with a logger, conditionally sets up the central manager if /// Bluetooth is already authorized, then broadcasts the initial state. Called once from - /// `BluetoothManager.init` via a fire-and-forget `Task`. + /// `ReliaBLEManager.init` via a fire-and-forget `Task`. func initialize(log: LoggingService) { configure(log: log) if CBCentralManager.authorization == .allowedAlways { @@ -318,8 +310,8 @@ public actor BluetoothActor { } private func broadcastState(_ state: BluetoothState) { - // nonisolated(unsafe) write — safe: written only from the actor's serial executor. Also - // serves as the replay value handed to each new `stateStream()` subscriber. + // Update the actor-isolated snapshot first; it backs the async `currentState` accessor and + // is replayed to each new `stateStream()` subscriber during registration. currentBluetoothState = state broadcast(state, to: stateContinuations) } diff --git a/Sources/ReliaBLE/BluetoothManager.swift b/Sources/ReliaBLE/BluetoothManager.swift deleted file mode 100644 index c8a9e84..0000000 --- a/Sources/ReliaBLE/BluetoothManager.swift +++ /dev/null @@ -1,216 +0,0 @@ -// -// BluetoothManager.swift -// ReliaBLE -// -// Created by Justin Bergen on 1/16/25. -// -// Copyright (c) 2025 Five3 Apps, LLC -// -// 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: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// 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. - -import CoreBluetooth -import Foundation - -/// Internal intermediary that forwards BLE operations to ``BluetoothActor`` and exposes -/// synchronous `AsyncStream` properties for consumption by ``ReliaBLEManager``. -/// -/// This class is removed in Step 4 when `ReliaBLEManager` collapses the indirection and -/// becomes `nonisolated Sendable`. -class BluetoothManager { - private let log: LoggingService - - // MARK: - Initialization - - /// Initializes the BluetoothManager with the provided LoggingService. Initializing a BluetoothManager does not - /// start the `CBCentralManager` *unless* the user has already authorized Bluetooth. This allows the integrating - /// app to control when and how Bluetooth autorization is presented to the user. - /// - /// When the integrating app desires to request Bluetooth authorization from iOS it can call ``authorize()``. - /// - /// - Parameter loggingService: The LoggingService to use for logging. - /// - Returns: A new instance of BluetoothManager. - init(loggingService: LoggingService) { - self.log = loggingService - - // `init` stays synchronous and dispatches actor setup via a fire-and-forget `Task` - // rather than awaiting it. The initial `setupCentralManager()` (if already - // `.allowedAlways`) and `updateState()` land on the actor's queue on the next - // run-loop turn, which is indistinguishable from prior behavior for callers that - // observe `state`/`currentState` after init. - // - // This does mean a caller that immediately awaits `startScanning()`/`stopScanning()` - // could in theory have that call's actor job ordered ahead of `initialize`'s, since - // Swift actors don't guarantee FIFO ordering across jobs enqueued from separate - // top-level Tasks. Both methods already guard on `centralManager == nil` and no-op - // with a log warning in that case, so the worst case is a missed scan start rather - // than a crash. Revisit if this race proves observable in practice — e.g. by making - // `init` `async` or exposing an explicit `await manager.ready()`. - Task { await BluetoothActor.shared.initialize(log: loggingService) } - } - - // MARK: - State - - /// A fresh `AsyncStream` of real-time state changes of the underlying Core Bluetooth system. - /// Each access mints an independent stream via the ``BluetoothActor`` broadcaster. - var state: AsyncStream { - BluetoothActor.shared.stateStream() - } - - /// Synchronous access to the current state of the underlying Core Bluetooth system. - /// Reads the `nonisolated(unsafe)` snapshot on ``BluetoothActor``. - var currentState: BluetoothState { - BluetoothActor.shared.currentBluetoothState - } - - // MARK: - Authorization - - /// Requests authorization to use Bluetooth. This method will throw an error if the user - /// has denied or restricted Bluetooth access. - /// - /// - Throws: An ``AuthorizationError`` error if the user has denied or restricted Bluetooth - /// access. - func authorize() async throws { - try await BluetoothActor.shared.authorize() - } - - // MARK: - Scanning - - /// A fresh `AsyncStream` that emits peripheral discovery events during scanning. It is meant to - /// be a lightweight advertisements feed for cases where the integrating app needs to process - /// individual advertisements. This stream does not replay; subscribe before scanning. - var peripheralDiscoveries: AsyncStream { - BluetoothActor.shared.peripheralDiscoveriesStream() - } - - /// A fresh `AsyncStream` that emits the current list of discovered peripherals, replaying the - /// latest list on subscription. - var discoveredPeripherals: AsyncStream<[Peripheral]> { - BluetoothActor.shared.discoveredPeripheralsStream() - } - - /// Starts (or restarts) scanning for peripherals. If scanning is already in progress, - /// this method will replace the current scan with a new scan. - /// - /// - Parameter services: An array of CBUUID objects that the app is interested in scanning - /// for. If the value is `nil`, the app scans for all peripherals. - /// - /// - Note: If Bluetooth is not authorized or powered on, this method will not start - /// scanning. - func startScanning(services: sending [CBUUID]? = nil) async { - await BluetoothActor.shared.startScanning(services: services) - } - - /// Stops scanning for peripherals. - func stopScanning() async { - await BluetoothActor.shared.stopScanning() - } - - // MARK: - Connection - - /// Initiates a connection to the given peripheral. - /// - /// - Parameter peripheral: A peripheral previously delivered via ``discoveredPeripherals``. - /// - Throws: ``PeripheralError/notFound`` if the peripheral is no longer known to the library. - func connect(to peripheral: Peripheral) async throws { - try await BluetoothActor.shared.connect(id: peripheral.id) - } -} - -// MARK: - Public Types - -/// A typealias for the authorization status of the Core Bluetooth manager. -/// -/// This typealias maps `CBManagerAuthorization` to `AuthorizationStatus`, providing a more readable and convenient -/// way to refer to the authorization status of the Bluetooth manager in the code. -public typealias AuthorizationStatus = CBManagerAuthorization - -/// Represents the various states of `BluetoothManager` and the underlying Core Bluetooth system. -/// -/// This enumeration provides a thread-safe representation of possible Bluetooth states that can be used across -/// concurrent environments. -public enum BluetoothState: Sendable { - /// The `BluetoothManager` is currently scanning for peripherals. - case scanning - /// Bluetooth is powered on and the `BluetoothManager` is ready to use. - case ready - /// Bluetooth is currently powered off on the device. - case poweredOff - /// Indicates the connection with the system service was momentarily lost. - /// - /// This state indicates that Bluetooth is trying to reconnect. After it reconnects, `BluetoothManager` updates the - /// state value. - case resetting - /// The app is not authorized to use Bluetooth. Associated value provides specific authorization status. - case unauthorized(AuthorizationStatus) - /// The platform doesn't support Bluetooth Low Energy. - case unsupported - /// The state of the `BluetoothManager` is unknown. - /// - /// This is a temporary state. After Core Bluetooth initializes or resets, `BluetoothManager` updates the - /// state value. - case unknown - - /// A user-friendly string representation of the `BluetoothState`. - /// - /// - Returns: A string describing the `BluetoothState`. - public var description: String { - switch self { - case .scanning: - "Scanning" - case .ready: - "Ready" - case .poweredOff: - "Powered Off" - case .resetting: - "Resetting" - case .unauthorized(let authorizationStatus): - switch authorizationStatus { - case .notDetermined: - "Not Authorized" - case .restricted: - "Restricted" - case .denied: - "Denied" - default: - "Unauthorized" - } - case .unsupported: - "Unsupported" - case .unknown: - "Unknown" - } - } -} - -// MARK: Errors - -/// A Swift error enumeration representing authorization-related errors in Bluetooth operations. -/// -/// This type conforms to Swift's `Error` protocol and encapsulates various authorization failures that may occur -/// during Bluetooth operations. -public enum AuthorizationError: Error { - /// The user has not yet been asked for Bluetooth permissions. - case unauthorized - /// The user explicitly denied Bluetooth access for this app. - case denied - /// Indicates this app isn’t authorized to use Bluetooth. - case restricted - /// The authorization status is unknown. - case unknown -} diff --git a/Sources/ReliaBLE/Documentation.docc/Documentation.md b/Sources/ReliaBLE/Documentation.docc/Documentation.md index 87ccd63..96bcaec 100644 --- a/Sources/ReliaBLE/Documentation.docc/Documentation.md +++ b/Sources/ReliaBLE/Documentation.docc/Documentation.md @@ -22,7 +22,7 @@ This is a temporary overview. ### Concurrency -- ``BluetoothActor`` +- ``ReliaBLEManager`` — a `nonisolated`, `Sendable` façade; all Core Bluetooth state is serialized internally ### Advanced Usage diff --git a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md index fe6baca..a690f4a 100644 --- a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md +++ b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md @@ -85,7 +85,7 @@ Example of starting and stopping a scan for all peripherals: ```swift // Check if Bluetooth is ready -if bleManager.currentState == .ready { +if await bleManager.currentState == .ready { await bleManager.startScanning() // Stop scanning after 10 seconds @@ -103,7 +103,7 @@ Example of scanning for peripherals with specific services (e.g., Heart Rate and import CoreBluetooth // Check if Bluetooth is ready -if bleManager.currentState == .ready { +if await bleManager.currentState == .ready { let serviceUUIDs = [CBUUID(string: "180D"), CBUUID(string: "180F")] // Heart Rate and Battery services await bleManager.startScanning(services: serviceUUIDs) diff --git a/Sources/ReliaBLE/Models/AdvertisementData.swift b/Sources/ReliaBLE/Models/AdvertisementData.swift index 66eed16..d59d205 100644 --- a/Sources/ReliaBLE/Models/AdvertisementData.swift +++ b/Sources/ReliaBLE/Models/AdvertisementData.swift @@ -31,7 +31,7 @@ import Foundation /// /// CoreBluetooth surfaces advertisement data as a loosely-typed `[String: Any]` dictionary keyed by /// `CBAdvertisementData*` constants. ``AdvertisementData`` extracts those values once, at discovery time and inside -/// ``BluetoothActor``, so the untyped dictionary never crosses the actor boundary into the public surface. +/// the library's internal concurrency domain, so the untyped dictionary never crosses into the public surface. /// /// - Note: This is a typed-only representation. Vendor-specific or otherwise non-standard advertisement keys are not /// currently surfaced; a raw escape hatch may be added in a future release if needed. @@ -63,7 +63,7 @@ public struct AdvertisementData: Sendable, Hashable { /// Extracts a typed snapshot from CoreBluetooth's raw advertisement dictionary. /// /// This initializer is intentionally internal: the untyped `[String: Any]` should be extracted exactly once, - /// inside ``BluetoothActor``, and never exposed to consumers. + /// inside the library, and never exposed to consumers. /// /// - Parameter rawAdvertisementData: The advertisement dictionary delivered by /// `centralManager(_:didDiscover:advertisementData:rssi:)`. diff --git a/Sources/ReliaBLE/Models/Peripheral.swift b/Sources/ReliaBLE/Models/Peripheral.swift index 654acaa..2652307 100644 --- a/Sources/ReliaBLE/Models/Peripheral.swift +++ b/Sources/ReliaBLE/Models/Peripheral.swift @@ -29,13 +29,13 @@ import Foundation /// An immutable, `Sendable` value snapshot of a Bluetooth peripheral and its metadata. /// /// A `Peripheral` carries no reference to the underlying CoreBluetooth `CBPeripheral`. The live `CBPeripheral` is -/// owned exclusively by ``BluetoothActor`` in an `id`-keyed registry that never escapes the actor. Operations that -/// need the live peripheral (such as ``ReliaBLEManager/connect(to:)``) forward the snapshot's ``id``; the actor looks -/// up the live reference and throws ``PeripheralError/notFound`` if the snapshot has since gone stale. +/// owned exclusively by the library in an `id`-keyed registry that never escapes its internal concurrency domain. +/// Operations that need the live peripheral (such as ``ReliaBLEManager/connect(to:)``) forward the snapshot's ``id``; +/// the actor looks up the live reference and throws ``PeripheralError/notFound`` if the snapshot has since gone stale. /// /// The integrating app can also construct a `Peripheral` from a known identifier *before* it has been discovered — /// for example, a wearable bound to the user's account — using ``init(id:)``. Such a snapshot has no -/// ``advertisement`` (and no live reference) until ``BluetoothActor`` matches it against a discovered `CBPeripheral`. +/// ``advertisement`` (and no live reference) until ReliaBLE matches it against a discovered `CBPeripheral`. /// /// Because it is a pure value type, a `Peripheral` is freely sendable across isolation domains and safe to hand to /// the integrating app for UI display. @@ -79,7 +79,7 @@ public struct Peripheral: Sendable, Identifiable, Hashable { self.init(id: id, cbIdentifier: nil, name: nil, rssi: nil, lastSeen: nil, advertisement: nil) } - /// Creates a fully-specified peripheral snapshot. Used internally by ``BluetoothActor`` at discovery time. + /// Creates a fully-specified peripheral snapshot. Used internally at discovery time. /// /// - Parameters: /// - id: Unique identifier for the peripheral. @@ -110,7 +110,7 @@ public struct Peripheral: Sendable, Identifiable, Hashable { public static func == (lhs: Peripheral, rhs: Peripheral) -> Bool { // Equality keys on `id` only: the identifier is unique and the matching between `Peripheral` snapshots and - // their live `CBPeripheral` is handled internally by ``BluetoothActor``. + // their live `CBPeripheral` is handled internally by the library. return lhs.id == rhs.id } } diff --git a/Sources/ReliaBLE/Models/PeripheralError.swift b/Sources/ReliaBLE/Models/PeripheralError.swift index da32cd5..8dfa6b4 100644 --- a/Sources/ReliaBLE/Models/PeripheralError.swift +++ b/Sources/ReliaBLE/Models/PeripheralError.swift @@ -30,7 +30,7 @@ public enum PeripheralError: Error, Sendable { /// The peripheral is no longer known to the library. /// /// A ``Peripheral`` is a value snapshot captured at discovery time. The live CoreBluetooth peripheral it refers to - /// is held inside ``BluetoothActor`` keyed by ``Peripheral/id``. If that reference has since been invalidated (for + /// is held internally by the library keyed by ``Peripheral/id``. If that reference has since been invalidated (for /// example, after Bluetooth reset) the snapshot is stale and operations that require the live peripheral throw /// this error. case notFound diff --git a/Sources/ReliaBLE/ReliaBLEManager.swift b/Sources/ReliaBLE/ReliaBLEManager.swift index 63d6bab..e4fc27e 100644 --- a/Sources/ReliaBLE/ReliaBLEManager.swift +++ b/Sources/ReliaBLE/ReliaBLEManager.swift @@ -30,11 +30,15 @@ import Foundation import Willow /// The main entry point for the ReliaBLE library. -public class ReliaBLEManager { +/// +/// `ReliaBLEManager` is a `nonisolated`, `Sendable` value-like façade: it owns no mutable state and +/// forwards every operation to a process-wide internal actor that serializes all Core Bluetooth +/// interactions. Because it is not bound to any actor, it is callable directly from `@MainActor` +/// SwiftUI code *and* from background actors without forcing a main-actor hop on background callers. +public final class ReliaBLEManager: Sendable { public let loggingService: LoggingService private let log: LoggingService - private let bluetoothManager: BluetoothManager /// Initializes the ReliaBLEManager with the provided configuration, or a default configuration if none is provided. /// @@ -49,7 +53,20 @@ public class ReliaBLEManager { loggingService.enabled = config.loggingEnabled log = loggingService - bluetoothManager = BluetoothManager(loggingService: loggingService) + + // `init` stays synchronous and dispatches actor setup via a fire-and-forget `Task` + // rather than awaiting it. The initial `setupCentralManager()` (if already + // `.allowedAlways`) and `updateState()` land on the actor's queue on the next + // run-loop turn. + // + // This does mean a caller that immediately awaits `startScanning()`/`stopScanning()` + // could in theory have that call's actor job ordered ahead of `initialize`'s, since + // Swift actors don't guarantee FIFO ordering across jobs enqueued from separate + // top-level Tasks. Both methods already guard on `centralManager == nil` and no-op + // with a log warning in that case, so the worst case is a missed scan start rather + // than a crash. Revisit if this race proves observable in practice — e.g. by making + // `init` `async` or exposing an explicit `await manager.ready()`. + Task { await BluetoothActor.shared.initialize(log: loggingService) } } // MARK: - State @@ -65,12 +82,14 @@ public class ReliaBLEManager { /// } /// ``` public var state: AsyncStream { - bluetoothManager.state + BluetoothActor.shared.stateStream() } - /// Synchronous, thread-safe access to the current state of the underlying Core Bluetooth system. + /// Asynchronous, thread-safe access to the current state of the underlying Core Bluetooth + /// system. The read is serialized on the library's internal concurrency domain, so the access + /// is `await`-ed. public var currentState: BluetoothState { - bluetoothManager.currentState + get async { await BluetoothActor.shared.currentBluetoothState } } /// Requests authorization to use Bluetooth. This method will throw an error if the user has denied or restricted @@ -78,7 +97,7 @@ public class ReliaBLEManager { /// /// - Throws: An ``AuthorizationError`` error if the user has denied or restricted Bluetooth access. public func authorizeBluetooth() async throws { - try await bluetoothManager.authorize() + try await BluetoothActor.shared.authorize() } // MARK: - Scanning @@ -91,14 +110,14 @@ public class ReliaBLEManager { /// ``discoveredPeripherals`` this stream does **not** replay a value on subscription — subscribe /// before you start scanning to avoid missing early advertisements. public var peripheralDiscoveries: AsyncStream { - bluetoothManager.peripheralDiscoveries + BluetoothActor.shared.peripheralDiscoveriesStream() } /// A multi-subscriber `AsyncStream` that emits the current de-duplicated list of discovered /// peripherals each time it changes. Each property access returns a fresh, independent stream; /// the current list is replayed as the first element on subscription. public var discoveredPeripherals: AsyncStream<[Peripheral]> { - bluetoothManager.discoveredPeripherals + BluetoothActor.shared.discoveredPeripheralsStream() } /// Starts scanning for peripheral devices, optionally filtering by specific services. @@ -109,12 +128,12 @@ public class ReliaBLEManager { /// - Note: If Bluetooth is not authorized or powered on, this method will not start scanning. It is the caller's /// responsibility to ensure that Bluetooth is authorized and powered on before calling this method. public func startScanning(services: sending [CBUUID]? = nil) async { - await bluetoothManager.startScanning(services: services) + await BluetoothActor.shared.startScanning(services: services) } /// Stops scanning for peripheral devices. public func stopScanning() async { - await bluetoothManager.stopScanning() + await BluetoothActor.shared.stopScanning() } // MARK: - Connection @@ -131,10 +150,87 @@ public class ReliaBLEManager { /// - Note: This currently only initiates the connection request. The full connection lifecycle is deferred to a /// later release. public func connect(to peripheral: Peripheral) async throws { - try await bluetoothManager.connect(to: peripheral) + try await BluetoothActor.shared.connect(id: peripheral.id) } +} + +// MARK: - Public Types + +/// A typealias for the authorization status of the Core Bluetooth manager. +/// +/// This typealias maps `CBManagerAuthorization` to `AuthorizationStatus`, providing a more readable and convenient +/// way to refer to the authorization status of the Bluetooth manager in the code. +public typealias AuthorizationStatus = CBManagerAuthorization + +/// Represents the various states of the underlying Core Bluetooth system, as surfaced by ReliaBLE. +/// +/// This enumeration provides a thread-safe representation of possible Bluetooth states that can be used across +/// concurrent environments. +public enum BluetoothState: Sendable { + /// ReliaBLE is currently scanning for peripherals. + case scanning + /// Bluetooth is powered on and ReliaBLE is ready to use. + case ready + /// Bluetooth is currently powered off on the device. + case poweredOff + /// Indicates the connection with the system service was momentarily lost. + /// + /// This state indicates that Bluetooth is trying to reconnect. After it reconnects, ReliaBLE updates the + /// state value. + case resetting + /// The app is not authorized to use Bluetooth. Associated value provides specific authorization status. + case unauthorized(AuthorizationStatus) + /// The platform doesn't support Bluetooth Low Energy. + case unsupported + /// The state of the underlying Core Bluetooth system is unknown. + /// + /// This is a temporary state. After Core Bluetooth initializes or resets, ReliaBLE updates the + /// state value. + case unknown - func testFunction() -> String { - return "Hello, this is ReliaBLE!" + /// A user-friendly string representation of the `BluetoothState`. + /// + /// - Returns: A string describing the `BluetoothState`. + public var description: String { + switch self { + case .scanning: + "Scanning" + case .ready: + "Ready" + case .poweredOff: + "Powered Off" + case .resetting: + "Resetting" + case .unauthorized(let authorizationStatus): + switch authorizationStatus { + case .notDetermined: + "Not Authorized" + case .restricted: + "Restricted" + case .denied: + "Denied" + default: + "Unauthorized" + } + case .unsupported: + "Unsupported" + case .unknown: + "Unknown" + } } } + +// MARK: Errors + +/// A Swift error enumeration representing authorization-related errors in Bluetooth operations. +/// +/// This type conforms to Swift's `Error` protocol and encapsulates various authorization failures that may occur +/// during Bluetooth operations. +public enum AuthorizationError: Error { + /// The user explicitly denied Bluetooth access for this app. + case denied + /// Indicates this app isn’t authorized to use Bluetooth. + case restricted + /// The authorization status is unknown. + case unknown +} diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 905dff1..e005610 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -27,10 +27,25 @@ import Testing @testable import ReliaBLEMock -@Test func correctFunction() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - let package = ReliaBLEManager() - #expect(package.testFunction() == "Hello, this is ReliaBLE!", "Incorrect response string") +@Test func reliaBLEManagerIsSendable() async throws { + let manager = ReliaBLEManager() + + // Capturing the manager in a `Task.detached` closure and exercising every public member is a + // compile-time proof that `ReliaBLEManager` is `Sendable` — the closure crosses an isolation + // boundary. The calls run against the mock with no central manager, so they safely no-op or + // throw, which is irrelevant: this test asserts compilation, not behavior. + await Task.detached { + _ = manager.loggingService + _ = await manager.currentState + _ = manager.state + _ = manager.peripheralDiscoveries + _ = manager.discoveredPeripherals + try? await manager.authorizeBluetooth() + await manager.startScanning() + await manager.startScanning(services: []) + await manager.stopScanning() + try? await manager.connect(to: Peripheral(id: "unused")) + }.value } @Test func peripheralIsSendable() async throws { diff --git a/docs/plans/manager-sendable-collapse-2026-06-23.md b/docs/plans/manager-sendable-collapse-2026-06-23.md new file mode 100644 index 0000000..c9a9bb5 --- /dev/null +++ b/docs/plans/manager-sendable-collapse-2026-06-23.md @@ -0,0 +1,72 @@ +# `ReliaBLEManager` → nonisolated `Sendable` + collapse `BluetoothManager`: Plan (Step 4 of 5) +*Issue #18 · 2026-06-23* + +## Goal + +Make `ReliaBLEManager` a **nonisolated `Sendable final class`** that forwards directly to `BluetoothActor.shared`, and delete the now-vestigial `BluetoothManager` indirection. This completes the architectural goal: the library is callable cleanly from `@MainActor` SwiftUI *and* background actors without forcing a MainActor hop on background callers. Also remove the dead `testFunction()` and `AuthorizationError.unauthorized`. + +## Background (verified current state, post-Step-3) + +The issue text predates the Step 3 implementation and is **stale** in several places. Verified actual state: + +- **`ReliaBLEManager.swift`** — `public class ReliaBLEManager` (not `final`, not `Sendable`, no isolation). Holds `private let bluetoothManager: BluetoothManager` (:37), created in `init` (:52). All public actions are **already `async`** (`authorizeBluetooth` :81, `startScanning` :110, `stopScanning` :115, `connect` :133). The three event surfaces are **already `AsyncStream`** (:67/:90/:97). `currentState` is **synchronous** (:72). Dead `testFunction()` at :135. +- **`BluetoothManager.swift`** — now a **thin pass-through** to `BluetoothActor.shared` (every method one-line forwards). Its only real job beyond forwarding: `init` fires `Task { await BluetoothActor.shared.initialize(log:) }` (:64), with a documented non-FIFO-ordering caveat (:51–63). The file **also hosts the public types** `AuthorizationStatus` typealias (:139), `BluetoothState` enum (:146), `AuthorizationError` enum (:206) — these are *not* tied to the `BluetoothManager` class. Several `BluetoothState` doc comments still say "`BluetoothManager`" (:143,148,150,156,163,165). +- **`BluetoothActor.swift`** — `@globalActor` internal `actor BluetoothActor` already does all real work via `BluetoothActor.shared`. Holds actor-isolated `currentBluetoothState` as the `currentState` backing **and** the `stateStream()` replay value. `initialize(log:)` doc comment references `ReliaBLEManager.init`. PeripheralManager is already gone (issue item #5 already done). The actor is **not** part of the public API — consumers interact only through ``ReliaBLEManager``. +- **No `import Combine` anywhere in Sources** (Step 3 complete). + +**Already-done issue items (no work needed):** PeripheralManager collapse (#5); public methods async (#2); AsyncStream surfaces (#3 partial); Demo already `await`s every call and consumes streams via `.task` (#8 — see below). + +**Blast radius of the changes:** +- `BluetoothManager` symbol: referenced **only** in `Sources/` (the files themselves) and `docs/`. **Zero** references in `Demo/` or `Tests/`. +- `currentState`: the **Demo never reads `manager.currentState`** — it mirrors state through the `state` stream into its own `@Observable` VM property (`CentralViewModel.swift:34,52`; view reads `viewModel.currentState`). Tests have **zero** `currentState` references. Only DocC examples read it synchronously (`GettingStarted.md:90,105`). +- `AuthorizationError.unauthorized`: **genuinely dead** — never thrown or matched (the `.unauthorized` grep hits are `CBManagerState` in a switch, DocC prose, and the Demo's own enum). +- `testFunction()`: exercised by one test (`ReliaBLEManagerTests.swift:30`) — that test must be removed/replaced. +- Demo consumes the manager only from SwiftUI `.task` / fire-and-forget `Task {}` in a non-`@MainActor` `@Observable` VM. Making the manager `Sendable` only *removes* latent cross-actor warnings; nothing breaks. + +**Design intent (audit report).** `docs/investigations/swift6-concurrency-audit-2026-05-13.md` is decisive: +- **§A (chosen architecture):** nonisolated `Sendable` `ReliaBLEManager`, *not* `@MainActor` — a `@MainActor` façade would force every background caller to hop MainActor → BluetoothActor per call. SwiftUI integrates via `.task { for await … }`; `@Observable` belongs in *consumer* view-models. +- **§7 (naming):** `BluetoothManager`'s responsibilities collapse into the actor; the `BluetoothActor` name is the chosen target. +- **currentState:** the report models it as **`get async`** post-refactor (`get async { await BluetoothActor.shared.currentBluetoothState }`), marked BREAKING. The PR#23 evaluation (`docs/investigations/copilot-pr23-review-evaluation-2026-06-11.md:74`) independently flags that the current `nonisolated(unsafe)` read of the *payloaded* `BluetoothState` enum can **tear** — a real data-race argument for moving the read onto the actor. + +## Approach + +Collapse the layering from `ReliaBLEManager → BluetoothManager → BluetoothActor.shared` down to `ReliaBLEManager → BluetoothActor.shared`. `BluetoothManager` carries no state worth keeping — every method is a one-line forward; its only real behavior is the fire-and-forget `initialize` Task in `init`, which moves verbatim into `ReliaBLEManager.init`. The three public types it incidentally hosts get relocated, then the file is deleted. + +`ReliaBLEManager` becomes `public final class ReliaBLEManager: Sendable`. After dropping the `bluetoothManager` stored property, it holds only two `Sendable let`s (`loggingService`, `log`), so `Sendable` conformance is trivial and requires no `@unchecked`. Every forwarder retargets from `bluetoothManager.X` to `BluetoothActor.shared.X`. The nonisolated stream factories (`stateStream()` etc.) are already callable from a nonisolated context, and the async actions already `await` the actor — so the retarget is mechanical. Lazy central-manager init and the `forceMock: true` literal stay untouched inside `BluetoothActor`. + +Two decisions were resolved at the Mid-flow checkpoint (see **Decisions**): + +- **`currentState` → `get async`**, reading the actor directly, and **drop `nonisolated(unsafe)`** from `currentBluetoothState` (its only other reader, `register(stateContinuation:)`, runs on-actor). This realizes the audit's §A design and eliminates the payloaded-enum tearing race the PR#23 eval flagged. Cost is near-zero: pre-1.0, no external consumers, and the Demo never reads `manager.currentState` — only DocC examples (`GettingStarted.md:90,105`) need an `await`. +- **Inline the public types into `ReliaBLEManager.swift`** (after the class): `AuthorizationStatus`, `BluetoothState`, `AuthorizationError`. Fix the stale "`BluetoothManager`" mentions in the `BluetoothState` doc comments during the move. DocC anchors key on symbol name, not file path, so the move breaks no links. + +## Work Items + +Land inside-out so the build stays green at each step. + +1. **Relocate public types out of `BluetoothManager.swift` into `ReliaBLEManager.swift`** (after the class). Move `AuthorizationStatus` (:139), `BluetoothState` (:146), `AuthorizationError` (:206). Delete the dead `AuthorizationError.unauthorized` case. Reword the `BluetoothState` doc comments that say "`BluetoothManager`" to describe ReliaBLE's behavior without naming the internal actor. *(Build green — types still exist, just moved.)* +2. **Retarget `ReliaBLEManager` to the actor.** Remove `private let bluetoothManager` (:37) and its `init` assignment (:52); move the fire-and-forget `Task { await BluetoothActor.shared.initialize(log: loggingService) }` (from `BluetoothManager.swift:64`, with its caveat comment) into `ReliaBLEManager.init`. Retarget every forwarder (`state`, `peripheralDiscoveries`, `discoveredPeripherals`, `authorizeBluetooth`, `startScanning`, `stopScanning`, `connect`) from `bluetoothManager.X` to `BluetoothActor.shared.X`. Make `currentState` `get async`. Do this as **one edit**: removing the property and adding the init Task together means exactly one `initialize` call, with no transient double-init. *(Build green.)* +3. **Make it `Sendable` + drop dead code.** Mark `public final class ReliaBLEManager: Sendable`; delete `testFunction()` (:135). Drop `nonisolated(unsafe)` from `BluetoothActor.currentBluetoothState` (:101) — make it a plain actor-isolated `var` — and reword its comment (it is now read on-actor by both `currentState`'s async getter and `register(stateContinuation:)`'s replay). *(Build green.)* +4. **Delete `BluetoothManager.swift`** (now empty). Update the `initialize(log:)` doc comment in `BluetoothActor.swift:202` ("Called once from `BluetoothManager.init`" → "`ReliaBLEManager.init`"). *(Build green.)* +5. **Tests — `ReliaBLEManagerTests.swift`.** Remove/replace the test at :30 that calls `testFunction()`. Add the acceptance test: capture a `ReliaBLEManager` into `Task.detached` and call every public method + read every stream — compilation proves `Sendable`. If `currentState` is async, `await` it where touched. *(Tests pass.)* +6. **DocC.** Add `await` to the `currentState` reads at `GettingStarted.md:90,105` (now async). Update `Documentation.md` Concurrency section to document ``ReliaBLEManager`` (not ``BluetoothActor``) as the public concurrency surface. Reword any public doc comments that cross-reference the internal actor. Per `CLAUDE.md`, DocC must track the public API. +7. **Hide `BluetoothActor` from the public interface.** Drop `public` from the actor and `shared` singleton. Tests retain access via `@testable import ReliaBLEMock`. No Demo or external-consumer impact — zero references outside `Sources/`. *(Build green.)* +8. **Verify.** `swift build && swift test`; grep confirms zero `BluetoothManager` references in `Sources/`; `forceMock: true` literal intact; lazy-init preserved; Demo target still builds and runs (no functional change expected — issue item #8 already satisfied by Step 3). + +## Decisions (confirmed at Mid-flow checkpoint) + +1. **`currentState` becomes `get async`**, reading the actor; `nonisolated(unsafe)` is dropped from `currentBluetoothState`. (Breaking signature change — acceptable pre-1.0, no external consumers, no Demo impact.) +2. **Public types are inlined into `ReliaBLEManager.swift`** (not relocated to `Models/`). +3. **`BluetoothActor` stays internal** — the global actor serializes Core Bluetooth state but is not exposed in the public API; ``ReliaBLEManager`` is the sole entry point. + +## Notes + +- **No double-`initialize` window.** `BluetoothManager` is already `internal` (never `public`) and is instantiated by nothing but `ReliaBLEManager` — zero references in `Tests/` or `Demo/`. Item 2 swaps the single instantiation for the single init Task in one edit, so `initialize` is called exactly once throughout. Item 1's type move leaves the still-internal class momentarily inert but harmless. +- **No surviving synchronous `currentState` readers.** The Demo mirrors state via the `state` stream into its own VM property and never reads `manager.currentState`; `Tests/` has zero `currentState` references. The async change touches only the two DocC examples (Item 6). + +## References + +- Issue #18; parent #10. Depends on #13 (Step 1), #17 (Step 2), #12 (Step 3) — all merged. +- Audit: `docs/investigations/swift6-concurrency-audit-2026-05-13.md` (Findings §4, Recommendations §A, Risks §7). +- PR#23 eval (tearing risk): `docs/investigations/copilot-pr23-review-evaluation-2026-06-11.md:74`. +- Prior step plans: `docs/plans/bluetooth-actor-migration-2026-06-08.md` (Step 1), `docs/plans/peripheral-sendable-struct-2026-06-13.md` (Step 2), `docs/plans/combine-to-asyncstream-2026-06-18.md` (Step 3). +- Key files: `Sources/ReliaBLE/ReliaBLEManager.swift`, `BluetoothManager.swift`, `BluetoothActor.swift`; `Tests/ReliaBLETests/ReliaBLEManagerTests.swift`; `Sources/ReliaBLE/Documentation.docc/`. diff --git a/docs/reviews/manager-sendable-collapse-plan-critique-2026-06-23.md b/docs/reviews/manager-sendable-collapse-plan-critique-2026-06-23.md new file mode 100644 index 0000000..17f24d2 --- /dev/null +++ b/docs/reviews/manager-sendable-collapse-plan-critique-2026-06-23.md @@ -0,0 +1,43 @@ +# Critique: `ReliaBLEManager` → nonisolated `Sendable` + collapse `BluetoothManager` + +**Reviewer:** opencode (grok-4.3) +**Date:** 2026-06-23 +**Plan:** `docs/plans/manager-sendable-collapse-2026-06-23.md` + +## 1. Top 3 Under-specified Seams + +**(a) Inside-out ordering / double-`initialize` risk** +Work Item 2 moves the fire-and-forget `Task { await BluetoothActor.shared.initialize(...) }` verbatim from `BluetoothManager.init` (BluetoothManager.swift:64) into `ReliaBLEManager.init`. +- The plan asserts “build stays green at each step” because types move first (Item 1), then the init change (Item 2). +- However, after Item 1 the `BluetoothManager` class still exists and its `init` still fires the `Task`. If a caller (or test) instantiates `BluetoothManager` directly between Items 1 and 2, `initialize` will be called twice. +- The plan never states that `BluetoothManager` is made `internal`/`fileprivate` or otherwise hidden after the type relocation, so the ordering claim is incomplete. +- Double-initialize is harmless today (actor guards `centralManager == nil`), but the seam is unstated. + +**(b) `currentBluetoothState` as plain actor-isolated `var`** +Work Item 3 drops `nonisolated(unsafe)` from `BluetoothActor.currentBluetoothState` (BluetoothActor.swift:101) once `currentState` becomes `get async`. +- The only remaining reader listed is `register(stateContinuation:)` (line 210), which runs on-actor and does `continuation.yield(currentBluetoothState)`. +- `broadcastState` (line 280) also writes it while on-actor. +- No nonisolated reader is shown after the change, so the seam appears safe, but the plan does not explicitly confirm that `stateStream()` callers or any test code still compile. + +**(c) `currentState` `get async` vs. existing synchronous surface** +Work Item 2 applies the decision “`currentState` → `get async`”. +- `ReliaBLEManager.currentState` is currently documented as “Synchronous, thread-safe” (ReliaBLEManager.swift:72). +- The plan states DocC examples in `GettingStarted.md:90,105` will be updated (Item 6), but does not list any other synchronous call sites that must be audited (e.g., internal helpers, future test helpers). +- The synchronous → async change is breaking; the plan treats it as acceptable pre-1.0, but the seam between “update DocC” and “guarantee no other sync readers” is not enumerated. + +## 2. Contradictions or Missing Dependencies + +- **No dependency on making `BluetoothManager` non-public.** Item 1 relocates the public types; Item 4 deletes the file. Between those steps `BluetoothManager` remains a public symbol that still performs initialization. The plan never says “make `BluetoothManager` internal after Item 1” or “add `@testable import` only for tests,” leaving a window for accidental double-initialize or stale references. +- **DocC update scope.** Item 6 only mentions `GettingStarted.md`. The plan does not check whether any other DocC pages or in-source documentation still reference the synchronous `currentState` or the `BluetoothManager` type after the collapse. + +## 3. Risk of Over-planning + +- The plan is already minimal. No sections need deletion; the work-item list is appropriately coarse. + +## 4. Questions Whose Answers Would Change Implementation Order + +1. Can `BluetoothManager` be made `internal` (or `@usableFromInline internal`) immediately after Item 1 without breaking any test target that still links against it? +2. Are there any non-DocC call sites (including generated DocC or other markdown) that read `currentState` synchronously today? +3. Does the test that exercises `testFunction()` (ReliaBLEManagerTests.swift:30) also instantiate `BluetoothManager` directly? If so, removing `testFunction` in Item 3 could mask a double-initialize scenario that only surfaces in tests. + +These three answers determine whether Items 2 and 3 can safely be merged or must remain strictly ordered. \ No newline at end of file From 3ffd9c0574edba2377e099a3ccdcd79f5749e71e Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 27 Jun 2026 12:56:47 -0600 Subject: [PATCH 20/27] Addressed Copilot's PR feedback --- Sources/ReliaBLE/ReliaBLEManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ReliaBLE/ReliaBLEManager.swift b/Sources/ReliaBLE/ReliaBLEManager.swift index e4fc27e..d52d018 100644 --- a/Sources/ReliaBLE/ReliaBLEManager.swift +++ b/Sources/ReliaBLE/ReliaBLEManager.swift @@ -226,7 +226,7 @@ public enum BluetoothState: Sendable { /// /// This type conforms to Swift's `Error` protocol and encapsulates various authorization failures that may occur /// during Bluetooth operations. -public enum AuthorizationError: Error { +public enum AuthorizationError: Error, Sendable { /// The user explicitly denied Bluetooth access for this app. case denied /// Indicates this app isn’t authorized to use Bluetooth. From 436ef62d88c041ce746cb96c5f349dc9611079df Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 27 Jun 2026 14:07:23 -0600 Subject: [PATCH 21/27] Added Swift 6 strict concurrency checking (Step 5 of 5, #10) Mark ReliaBLEConfig Sendable, and add DocC concurrency guide. Make Swift 6 language mode and complete strict concurrency explicit in Package.swift for the ReliaBLE and ReliaBLEMock targets rather than relying on the swift-tools-version default, mark ReliaBLEConfig as Sendable, and document the actor isolation contract in a new DocC Concurrency article. Also record the planning and critique notes for this step of the Swift 6 migration. --- Package.swift | 9 +- .../Documentation.docc/Documentation.md | 5 +- .../Documentation.docc/Topics/Concurrency.md | 83 +++++++++++++++++ Sources/ReliaBLE/Logging/LogWriters.swift | 5 + Sources/ReliaBLE/ReliaBLEConfig.swift | 2 +- ...strict-concurrency-flag-flip-2026-06-27.md | 92 +++++++++++++++++++ ...ency-flag-flip-plan-critique-2026-06-27.md | 26 ++++++ 7 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md create mode 100644 docs/plans/strict-concurrency-flag-flip-2026-06-27.md create mode 100644 docs/reviews/strict-concurrency-flag-flip-plan-critique-2026-06-27.md diff --git a/Package.swift b/Package.swift index 406e30e..f07921e 100644 --- a/Package.swift +++ b/Package.swift @@ -21,7 +21,8 @@ let package = Package( targets: [ .target( name: "ReliaBLE", - dependencies: ["Willow"] + dependencies: ["Willow"], + swiftSettings: [.swiftLanguageMode(.v6), .enableExperimentalFeature("StrictConcurrency")] ), .target( name: "ReliaBLEMock", @@ -29,11 +30,13 @@ let package = Package( "Willow", .product(name: "CoreBluetoothMock", package: "IOS-CoreBluetooth-Mock") ], - exclude: ["ReliaBLE/CBCentralManagerFactory.swift", "ReliaBLE/Documentation.docc"] + exclude: ["ReliaBLE/CBCentralManagerFactory.swift", "ReliaBLE/Documentation.docc"], + swiftSettings: [.swiftLanguageMode(.v6), .enableExperimentalFeature("StrictConcurrency")] ), .testTarget( name: "ReliaBLETests", - dependencies: ["ReliaBLEMock"] + dependencies: ["ReliaBLEMock"], + swiftSettings: [.swiftLanguageMode(.v6), .enableExperimentalFeature("StrictConcurrency")] ), ] ) diff --git a/Sources/ReliaBLE/Documentation.docc/Documentation.md b/Sources/ReliaBLE/Documentation.docc/Documentation.md index 96bcaec..68f5de0 100644 --- a/Sources/ReliaBLE/Documentation.docc/Documentation.md +++ b/Sources/ReliaBLE/Documentation.docc/Documentation.md @@ -20,9 +20,10 @@ This is a temporary overview. - ``PeripheralDiscoveryEvent`` - ``PeripheralError`` -### Concurrency +### Concurrency & Isolation -- ``ReliaBLEManager`` — a `nonisolated`, `Sendable` façade; all Core Bluetooth state is serialized internally +- +- ``ReliaBLEManager`` ### Advanced Usage diff --git a/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md new file mode 100644 index 0000000..afaaa53 --- /dev/null +++ b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md @@ -0,0 +1,83 @@ +# Concurrency + +How ReliaBLE isolates Bluetooth state and how to safely call it from your app. + +## Overview + +ReliaBLE is built with the Swift 6 language mode and complete strict concurrency +checking. Its public surface is designed so you can call it from anywhere — a +SwiftUI view on the `@MainActor`, a background actor, or a detached `Task` — +without manual locking or forced actor hops. + +### The isolation contract + +``ReliaBLEManager`` is a `nonisolated`, `Sendable` `final class`. It owns no +mutable state itself; instead it forwards every operation to an internal +`@globalActor` (`BluetoothActor`) that serializes all Core Bluetooth +interactions. Because the manager is `Sendable` and nonisolated, you can hold a +single instance and share it freely across isolation domains — there is no +implicit `@MainActor` requirement and no forced main-thread hop. + +``` +ReliaBLEManager (nonisolated, Sendable) + │ forwards async calls + ▼ +BluetoothActor (@globalActor, internal) + │ serializes all access + ▼ +CBCentralManager delegate shim + │ + ▼ +CoreBluetooth +``` + +`BluetoothActor` is an **internal** implementation detail. Consumers must not +reference it; interact only through ``ReliaBLEManager``. + +### Calling actions + +All mutating actions are `async` and hop onto the Bluetooth actor for you: + +- ``ReliaBLEManager/authorizeBluetooth()`` +- ``ReliaBLEManager/startScanning(services:)`` +- ``ReliaBLEManager/stopScanning()`` +- ``ReliaBLEManager/connect(to:)`` + +The current Bluetooth state is exposed as an `async` getter, +``ReliaBLEManager/currentState``: + +```swift +let state = await manager.currentState +``` + +### Observing events + +ReliaBLE exposes three event surfaces, each of which returns a **fresh, +per-subscriber** `AsyncStream`. Iterate them with `for await`, ideally from a +SwiftUI `.task` so iteration is tied to the view's lifetime: + +```swift +.task { + for await state in manager.state { + self.state = state + } +} +``` + +Replay semantics differ per stream: + +- ``ReliaBLEManager/state`` — replays the **latest** value to new subscribers + (`.bufferingNewest(1)`). +- ``ReliaBLEManager/discoveredPeripherals`` — replays the **latest** value to new + subscribers (`.bufferingNewest(1)`). +- ``ReliaBLEManager/peripheralDiscoveries`` — does **not** replay; a new + subscriber only receives discoveries that occur after it begins iterating. + +Because each call returns an independent stream, multiple parts of your app can +observe the same surface concurrently without interfering with one another. + +### Value types + +The model types you receive — ``Peripheral``, ``AdvertisementData``, and +``PeripheralDiscoveryEvent`` — are `Sendable` value structs, so they cross +isolation boundaries freely. diff --git a/Sources/ReliaBLE/Logging/LogWriters.swift b/Sources/ReliaBLE/Logging/LogWriters.swift index 368de70..dede711 100644 --- a/Sources/ReliaBLE/Logging/LogWriters.swift +++ b/Sources/ReliaBLE/Logging/LogWriters.swift @@ -45,6 +45,11 @@ public typealias ConsoleWriter = Willow.ConsoleWriter /// The OSLogWriter class runs all modifiers in the order they were created and passes the resulting message /// off to an OSLog with the specified subsystem and category. +/// +/// Sendable is synthesized for this `final class` from immutable `let` stored properties of Sendable types +/// (`String`, `[LogModifier]`, `OSLog`). No explicit conformance is required under Swift 6 strict concurrency. +/// If a future Willow or SDK change breaks synthesis, fall back to `: LogModifierWriter, @unchecked Sendable` +/// (matching upstream `Willow.OSLogWriter`). Do not apply `@unchecked` unless a build actually fails. public final class OSLogWriter: LogModifierWriter { public let subsystem: String public let category: String diff --git a/Sources/ReliaBLE/ReliaBLEConfig.swift b/Sources/ReliaBLE/ReliaBLEConfig.swift index 569c11b..23161a2 100644 --- a/Sources/ReliaBLE/ReliaBLEConfig.swift +++ b/Sources/ReliaBLE/ReliaBLEConfig.swift @@ -32,7 +32,7 @@ public typealias LogLevel = Willow.LogLevel /// The `ReliaBLEConfig` struct defines the configuration options for the ReliaBLE library. This struct is used to /// configure the logging service used by ReliaBLE. -public struct ReliaBLEConfig { +public struct ReliaBLEConfig: Sendable { /// The log levels to that will be send to ``logWriters`` for logging. The default value is all log levels. public var logLevels = LogLevel.all diff --git a/docs/plans/strict-concurrency-flag-flip-2026-06-27.md b/docs/plans/strict-concurrency-flag-flip-2026-06-27.md new file mode 100644 index 0000000..38078f8 --- /dev/null +++ b/docs/plans/strict-concurrency-flag-flip-2026-06-27.md @@ -0,0 +1,92 @@ +# Logging polish + strict-concurrency flag flip + DocC update: Plan (Step 5 of 5) +*Issue #19 · 2026-06-27* + +## Goal + +Final polish pass closing out the Swift 6 migration: make `ReliaBLEConfig` explicitly `Sendable`, pin **Swift 6 language mode + complete strict concurrency** in `Package.swift` for both library targets, and add a DocC `Concurrency.md` page documenting the isolation contract. Steps 1–4 already delivered the architecture; this step makes the guarantees explicit and durable. + +## Background (verified current state) + +**Build status — already clean today.** `swift build` succeeds with no warnings in our sources. swift-tools-version is `6.1`, which defaults both targets to the **Swift 6 language mode** (= complete strict concurrency) even though `Package.swift` sets no `swiftSettings`. So the code already compiles under the regime this step makes explicit — the flag flip **pins intent**, it does not unlock new diagnostics. (A forced `-Xswiftc -strict-concurrency=complete` build also reports clean.) + +**Logging layer (deliverables #1–#2) — both essentially no-ops, verified:** +- `ReliaBLEConfig` (`ReliaBLEConfig.swift:20`) is a struct with **no explicit `Sendable`**. All four stored fields are Sendable: `logLevels: LogLevel` (Willow `LogLevel: Sendable`, `LogLevel.swift:30`), `logWriters: [LogWriter]` (Willow `public protocol LogWriter: Sendable`, `LogWriter.swift:31`, so the existential array is Sendable), `logQueue: DispatchQueue` (Sendable), `loggingEnabled: Bool`. ⇒ Adding `: Sendable` compiles with **no other change**. +- `OSLogWriter` (`LogWriters.swift:35`) is `public final class OSLogWriter: LogModifierWriter` with **no explicit Sendable**. Since `LogModifierWriter: LogWriter: Sendable`, the clean v6 build **proves OSLogWriter already satisfies Sendable implicitly**. No `@unchecked Sendable` is needed today — it is only a contingency for a future SDK where the inferred conformance breaks. +- `LoggingService` is already `public final class … : Sendable` (`LoggingService.swift:20`). The `enabled` last-write race is deliberately left as-is (issue decision; tracked upstream as Willow#3) — no DocC note. + +**Public concurrency surface (delivered Steps 1–4 — informs DocC + tests):** +- `ReliaBLEManager` is `public final class ReliaBLEManager: Sendable`, nonisolated, forwarding to the internal `@globalActor BluetoothActor.shared` (`ReliaBLEManager.swift:64`). Actions are `async` (`authorizeBluetooth()`, `startScanning(services:)`, `stopScanning()`, `connect(to:)`); `currentState` is `get async`. +- Three event surfaces — `state`, `peripheralDiscoveries`, `discoveredPeripherals` — each return a **fresh per-subscriber** `AsyncStream`. Replay via `.bufferingNewest(1)` for `state` and `discoveredPeripherals`; `peripheralDiscoveries` does **not** replay. +- `BluetoothActor` is **internal**. `Peripheral`, `AdvertisementData`, `PeripheralDiscoveryEvent` are `Sendable` value structs. + +**Tests (deliverables #6–#7) — already exist:** +- `ReliaBLEManagerTests.swift:30` `reliaBLEManagerIsSendable` — captures a `ReliaBLEManager` in `Task.detached` and exercises every public member (streams, `currentState`, `authorizeBluetooth`, scanning, `connect`). +- `ReliaBLEManagerTests.swift:51` `peripheralIsSendable` — captures `Peripheral` in `Task.detached`. +- ⇒ No new tests needed; this step **verifies** they still pass under the explicit flags. + +**DocC catalog** (`Sources/ReliaBLE/Documentation.docc/`, excluded from `ReliaBLEMock`): +- `Documentation.md:1-29` already has a `### Concurrency` topic group with a one-line `ReliaBLEManager` entry but **no dedicated article**. Articles live under `Topics/` (e.g. `Topics/Logging.md`) and are linked via ``. + +## Approach + +The substantive work is small and low-risk because the code already compiles under Swift 6 mode. Three concrete edits plus documentation: + +1. One-line `Sendable` on `ReliaBLEConfig` — proven safe by the field audit above. +2. Pin language mode + strict concurrency in `Package.swift` on **both** `ReliaBLE` and `ReliaBLEMock`, making the guarantee independent of the tools-version default. `.swiftLanguageMode(.v6)` already implies complete strict concurrency; `.enableExperimentalFeature("StrictConcurrency")` is belt-and-suspenders and harmless. +3. Leave `OSLogWriter` as-is (conformance already satisfied); add only a short comment recording why, with the `@unchecked Sendable` fallback noted as a contingency — do **not** apply it unless a build actually fails. +4. New DocC `Concurrency.md` article documenting the isolation contract, linked from `Documentation.md`. Optional ASCII isolation graph inline (no binary asset to manage). + +Verification gates the step: `swift build`, `swift test`, and `swift package generate-documentation` must all be clean. + +Demo cross-actor validation (audit "Preventive Measures") is **outside this step's acceptance criteria** — Issue #19 scopes acceptance to the library. If wanted, delegate it separately to a sub-agent that reads `Demo/CLAUDE.md` first. + +## Work Items + +Ordered so the build stays green at each step. + +1. **`ReliaBLEConfig: Sendable`.** `ReliaBLEConfig.swift:20` — change `public struct ReliaBLEConfig {` → `public struct ReliaBLEConfig: Sendable {`. No other change. *(Build green.)* + +2. **Pin flags in `Package.swift`.** Add an identical `swiftSettings:` to both targets. `swiftSettings:` is a sibling argument to `dependencies:` (and, for the mock, `exclude:`) in the target literal — append it last, comma-separated: + ```swift + .target( + name: "ReliaBLE", + dependencies: ["Willow"], + swiftSettings: [.swiftLanguageMode(.v6), .enableExperimentalFeature("StrictConcurrency")] + ), + .target( + name: "ReliaBLEMock", + dependencies: [ "Willow", .product(name: "CoreBluetoothMock", package: "IOS-CoreBluetooth-Mock") ], + exclude: ["ReliaBLE/CBCentralManagerFactory.swift", "ReliaBLE/Documentation.docc"], + swiftSettings: [.swiftLanguageMode(.v6), .enableExperimentalFeature("StrictConcurrency")] + ), + ``` + Leave the `ReliaBLETests` target as-is. If strict checking surfaces issues from the `CBM*` aliases, the fix belongs in `CoreBluetoothMockAliases.swift` (escape hatches like `@preconcurrency`/`@retroactive @unchecked Sendable` are permitted in `ReliaBLEMock` only). Not expected — v6 is already the effective default and the target builds clean. *(Build green.)* + +3. **`OSLogWriter` comment.** `LogWriters.swift:35` — keep the declaration unchanged. Add a one-line comment noting Sendable is satisfied via `LogModifierWriter: LogWriter: Sendable`, with the `@unchecked Sendable` fallback recorded as a contingency (matching upstream `Willow.OSLogWriter`). Apply `@unchecked` only if a build genuinely fails. *(Build green.)* + +4. **DocC `Concurrency.md`.** Create `Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md`. DocC auto-compiles every `.md` in the catalog, so no manifest/index entry is required — the curation link in Work Item 5 is what surfaces it in navigation. Document: + - `ReliaBLEManager` is `Sendable` + nonisolated → callable from `@MainActor` SwiftUI **and** background actors with no forced MainActor hop. + - All actions are `async`; `currentState` is `get async`. + - Event surfaces return **fresh per-subscriber** `AsyncStream`s; show the SwiftUI `.task { for await … }` pattern; note replay semantics (`state`/`discoveredPeripherals` replay latest; `peripheralDiscoveries` does not). + - `BluetoothActor` is internal — consumers must not reference it. + - *(Optional)* inline ASCII isolation graph: `ReliaBLEManager (nonisolated, Sendable) → BluetoothActor (@globalActor) → delegate shim → CoreBluetooth`. + +5. **Link the article.** `Documentation.md:24-26` — the `### Concurrency` group currently holds a single bullet (the `ReliaBLEManager` symbol entry). Add `- ` as a sibling bullet in that same group, keeping the symbol entry. (A `### Concurrency` heading with two bullets is valid DocC curation.) + +6. **Verify.** + - `swift build` clean on both targets. + - `swift test` green — confirm `reliaBLEManagerIsSendable` (:30) and `peripheralIsSendable` (:51) pass and still cover the full public surface (async `currentState`, `connect`, all three streams). + - `swift package generate-documentation --target ReliaBLE` (catalog lives only in the production target) builds **clean**; `Concurrency.md` renders in navigation under the Concurrency group. + +## Open Questions + +- **Demo validation in scope?** The audit lists Demo cross-actor validation under Preventive Measures, but Issue #19's acceptance criteria are library-only. Default: out of scope here — delegate separately (sub-agent reads `Demo/CLAUDE.md` first) if wanted. + +## References + +- Issue #19 (Step 5 of 5); parent #10. Depends on #13 / #17 / #12 / #18 — Steps 1–4, merged. +- Audit: `docs/investigations/swift6-concurrency-audit-2026-05-13.md` (Recommendations §C, Migration order Step 5, Preventive Measures). +- Upstream `enabled` race: itsniper/Willow#3 (no ReliaBLE-side work). +- Willow Sendable constraints: `.build/checkouts/Willow/Source/LogWriter.swift:31`, `LogLevel.swift:30`. +- Prior step plans: `docs/plans/bluetooth-actor-migration-2026-06-08.md`, `peripheral-sendable-struct-2026-06-13.md`, `combine-to-asyncstream-2026-06-18.md`, `manager-sendable-collapse-2026-06-23.md`. +- Key files: `Sources/ReliaBLE/ReliaBLEConfig.swift:20`, `Logging/LogWriters.swift:35`, `Package.swift`, `Documentation.docc/Documentation.md`, `Tests/ReliaBLETests/ReliaBLEManagerTests.swift:30,51`. diff --git a/docs/reviews/strict-concurrency-flag-flip-plan-critique-2026-06-27.md b/docs/reviews/strict-concurrency-flag-flip-plan-critique-2026-06-27.md new file mode 100644 index 0000000..4070b0b --- /dev/null +++ b/docs/reviews/strict-concurrency-flag-flip-plan-critique-2026-06-27.md @@ -0,0 +1,26 @@ +# Critique: strict-concurrency-flag-flip-2026-06-27.md + +**Scope** — One-page review of the Step-5 plan for Issue #19 (final Swift 6 migration polish). + +## 1. Under-specified seams (file:line) + +- `Package.swift:24-29` — `ReliaBLEMock` target literal already contains `exclude:`. Plan does not state where `swiftSettings:` must be inserted relative to `exclude:`. Implementer could produce invalid Swift syntax. +- `Documentation.docc/Documentation.md` — plan references the existing `### Concurrency` topic group but gives no line number or exact insertion point for ``. Placement could break list structure if the group is currently a single bullet. +- `swift package generate-documentation` gate — no mention of required `--target ReliaBLE` flag (DocC catalog lives only in the production target). + +## 2. Contradictions / missing dependencies + +None. Plan correctly notes that `swift-tools-version:6.1` already implies Swift 6 + complete concurrency, Willow `LogWriter:Sendable` is accurate, and the two `Task.detached` smoke tests already exist. + +## 3. Over-planning risk (cut or simplify) + +- “Background (verified current state)” section is ~40 lines of repetition; an implementer will observe the clean build themselves. Reduce to 2–3 bullets. +- “Open Questions” and “Diagram format” bullets add no actionable decisions for a 4-edit task — delete. +- Deliverable #3 (`OSLogWriter` comment) is cosmetic only; plan itself says “apply `@unchecked` only if build fails.” Consider dropping the item. + +## 4. Questions that change order/correctness + +- Must `swift package generate-documentation` succeed with zero warnings, or is the gate only that the new article renders? (Affects whether the step must actually invoke the plugin.) +- Does `Concurrency.md` require an explicit entry in the DocC catalog index, or does the `` link in `Documentation.md` suffice? Answer determines if a second DocC edit is needed. + +**Recommendation** — Trim verbosity and the two open-question items; add one clarifying sentence on `swiftSettings` placement and the exact insertion point in `Documentation.md`. The plan is otherwise minimal and correct. \ No newline at end of file From de64b184f37171b3bbeb3eda3b1ff468c81300b5 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 27 Jun 2026 14:56:09 -0600 Subject: [PATCH 22/27] [demo] Route SwiftData writes through background DeviceStoreActor (#20) Move all SwiftData persistence in the Central tab off the main thread by introducing `DeviceStoreActor`, a `ModelActor` created off-main via `create(container:)`. `CentralView` now wires reliaBLE streams and the store through a single `.task`/`withTaskGroup`, `CentralViewModel` keeps only UI state and BLE actions, and `Demo/AGENTS.md` documents the new pattern. Adds the accompanying planning doc. --- Demo/AGENTS.md | 12 +- .../ReliaBLE Demo/Central/CentralView.swift | 63 +++++---- .../Central/CentralViewModel.swift | 93 ++++--------- .../Central/DeviceStoreActor.swift | 129 ++++++++++++++++++ .../demo-background-swiftdata-2026-06-27.md | 113 +++++++++++++++ 5 files changed, 307 insertions(+), 103 deletions(-) create mode 100644 Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift create mode 100644 docs/plans/demo-background-swiftdata-2026-06-27.md diff --git a/Demo/AGENTS.md b/Demo/AGENTS.md index e3b4eda..46e18ab 100644 --- a/Demo/AGENTS.md +++ b/Demo/AGENTS.md @@ -42,7 +42,7 @@ Scheme: **"ReliaBLE Demo"**. `ReliaBLE Demo.xcodeproj` lives at `Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj`. The app is a 3-tab SwiftUI app: -- **Central** (`Central/`) — uses `ReliaBLEManager` (the library) to scan, surfaces discoveries into a SwiftData store. `CentralViewModel` is the only place the library's Combine publishers are subscribed. State observed via `@Observable`. +- **Central** (`Central/`) — uses `ReliaBLEManager` (the library) to scan, surfaces discoveries into a SwiftData store. `CentralView` subscribes to the library's `AsyncStream` surfaces in `.task { for await … }` loops; `CentralViewModel` holds UI state and BLE actions only. All SwiftData writes go through `DeviceStoreActor` (`@ModelActor`). State observed via `@Observable`. - **Peripheral** (`Peripheral/`) — uses raw `CBPeripheralManager` directly (not the library — the library doesn't yet expose peripheral mode). This is intentional; the demo shows both sides of BLE even though only the central side flows through ReliaBLE. - **Settings** (`Settings/`) — app-level toggles. @@ -50,7 +50,7 @@ Scheme: **"ReliaBLE Demo"**. - `ReliaBLE_DemoApp.swift` constructs a single `ReliaBLEManager` with `loggingEnabled = true` and injects it via a custom `EnvironmentKey` (`@Environment(\.bleManager)`). - There's also a `BLEManagerKey.defaultValue` for previews — keep that working when changing the env injection. -- A `ModelContainer` for `Device` and `DiscoveryEvent` is set on the root scene. `CentralViewModel` writes both kinds of records as the library emits events. +- A `ModelContainer` for `Device` and `DiscoveryEvent` is set on the root scene. `CentralView` creates a `DeviceStoreActor` off the main thread via `DeviceStoreActor.create(container:)` and routes all SwiftData writes through it as the library emits events. Reads stay on `@MainActor` via `@Query`. ## Swift Concurrency @@ -58,8 +58,12 @@ The library is built with Swift 6 and **complete concurrency checking**. Therefo ### SwiftData models -- `Device` — one row per unique discovered peripheral (by `ReliaBLE.Peripheral.id`), updated on each `discoveredPeripherals` publisher tick. -- `DiscoveryEvent` — one row per raw advertisement (every `peripheralDiscoveries` event). Grows quickly; "Clear All Data" in the Central view nukes both tables. +- `Device` — one row per unique discovered peripheral (by `ReliaBLE.Peripheral.id`), updated on each `discoveredPeripherals` stream tick via `DeviceStoreActor.syncDevices`. +- `DiscoveryEvent` — one row per raw advertisement (every `peripheralDiscoveries` event), inserted via `DeviceStoreActor.insertDiscovery`. Grows quickly; "Clear All Data" in the Central view nukes both tables. + +### Off-main persistence pattern + +`DeviceStoreActor` is the canonical pattern for library consumers: hold `ReliaBLEManager` on the main actor, consume `AsyncStream`s in `.task`, and `await` the store for every SwiftData write. See the file-level comment in `Central/DeviceStoreActor.swift`. ## Don't diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift index f37dc93..f3d6128 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift @@ -33,17 +33,17 @@ import ReliaBLE struct CentralView: View { @Environment(\.modelContext) private var modelContext @Environment(\.bleManager) private var reliaBLE - + @Query private var discoveries: [DiscoveryEvent] @Query private var devices: [Device] - + @State private var viewModel = CentralViewModel() @State private var selectedView: String = "Devices" - + var body: some View { NavigationSplitView { Text("ReliaBLE state: \(viewModel.currentState.description)") - + if case BluetoothState.unauthorized(let authState) = viewModel.currentState, authState == .notDetermined { Button("Authorize Bluetooth") { viewModel.authorizeBluetooth() @@ -53,7 +53,7 @@ struct CentralView: View { TextField("Enter service UUIDs (comma-separated)", text: $viewModel.servicesInput) .textFieldStyle(.roundedBorder) .padding() - + Button("Start Scanning") { viewModel.startScanning() } @@ -64,14 +64,14 @@ struct CentralView: View { } .buttonStyle(.bordered) } - + Picker("Select View", selection: $selectedView) { Text("Devices").tag("Devices") Text("Discoveries").tag("Discoveries") } .pickerStyle(SegmentedPickerStyle()) .padding() - + Group { if selectedView == "Devices" { deviceList @@ -94,26 +94,31 @@ struct CentralView: View { } detail: { Text("Select a device") } - .onAppear { - viewModel.setDependencies(modelContext: modelContext, reliaBLE: reliaBLE) - } - .task { - for await state in reliaBLE.state { - viewModel.updateState(state) - } - } - .task { - for await discoveryEvent in reliaBLE.peripheralDiscoveries { - viewModel.insertDiscovery(discoveryEvent, into: modelContext) - } - } .task { - for await peripherals in reliaBLE.discoveredPeripherals { - viewModel.syncDevices(peripherals, into: modelContext) + let manager = reliaBLE + let store = await DeviceStoreActor.create(container: modelContext.container) + viewModel.setDependencies(deviceStore: store, reliaBLE: manager) + + await withTaskGroup(of: Void.self) { group in + group.addTask { + for await state in manager.state { + await viewModel.updateState(state) + } + } + group.addTask { + for await discoveryEvent in manager.peripheralDiscoveries { + await store.insertDiscovery(discoveryEvent) + } + } + group.addTask { + for await peripherals in manager.discoveredPeripherals { + await store.syncDevices(peripherals) + } + } } } } - + private var deviceList: some View { List { ForEach(devices) { device in @@ -126,12 +131,12 @@ struct CentralView: View { } } .onDelete { offsets in - let itemsToDelete = offsets.map { devices[$0] } - viewModel.deleteDevices(itemsToDelete) + let ids = offsets.map { devices[$0].persistentModelID } + viewModel.deleteDevices(ids: ids) } } } - + private var discoveriesList: some View { List { ForEach(discoveries) { discoveryEvent in @@ -144,8 +149,8 @@ struct CentralView: View { } } .onDelete { offsets in - let itemsToDelete = offsets.map { discoveries[$0] } - viewModel.deleteDiscoveries(itemsToDelete) + let ids = offsets.map { discoveries[$0].persistentModelID } + viewModel.deleteDiscoveries(ids: ids) } } } @@ -157,4 +162,4 @@ struct CentralView: View { for: [Device.self, DiscoveryEvent.self], inMemory: true ) -} +} \ No newline at end of file diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift index b7b7a4f..65b8862 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift @@ -33,101 +33,54 @@ import ReliaBLE @Observable class CentralViewModel { var currentState: BluetoothState = .unknown var servicesInput = "" - - private var modelContext: ModelContext? + + private var deviceStore: DeviceStoreActor? private var reliaBLE: ReliaBLEManager? - - func setDependencies(modelContext: ModelContext, reliaBLE: ReliaBLEManager) { - self.modelContext = modelContext + + func setDependencies(deviceStore: DeviceStoreActor, reliaBLE: ReliaBLEManager) { + self.deviceStore = deviceStore self.reliaBLE = reliaBLE } - + // MARK: - Stream Handlers // - // Fed by the `.task { for await … }` loops in `CentralView`. Each handler runs on the main - // actor before touching `@Observable` state or SwiftData. - + // Fed by the `.task { for await … }` loops in `CentralView`. Only UI state updates + // run on the main actor; SwiftData writes go through `DeviceStoreActor`. + @MainActor func updateState(_ state: BluetoothState) { currentState = state } - - @MainActor - func insertDiscovery(_ discoveryEvent: PeripheralDiscoveryEvent, into modelContext: ModelContext) { - let event = DiscoveryEvent( - peripheralIdentifier: discoveryEvent.id.uuidString, - name: discoveryEvent.name ?? "Unknown", - rssi: discoveryEvent.rssi, - timestamp: Date() - ) - modelContext.insert(event) - try? modelContext.save() - } - - @MainActor - func syncDevices(_ peripherals: [Peripheral], into modelContext: ModelContext) { - do { - let allDevices = try modelContext.fetch(FetchDescriptor()) - for peripheral in peripherals { - if let existingDevice = allDevices.first(where: { $0.id == peripheral.id }) { - existingDevice.name = peripheral.name - existingDevice.lastSeen = peripheral.lastSeen - } else { - let newDevice = Device(id: peripheral.id, name: peripheral.name, lastSeen: Date()) - modelContext.insert(newDevice) - } - } - try modelContext.save() - } catch { - print("Error fetching devices: \(error)") - } - } - + func authorizeBluetooth() { Task { try? await reliaBLE?.authorizeBluetooth() } } - + func startScanning() { let services = parseServices(from: servicesInput) Task { await reliaBLE?.startScanning(services: services) } } - + func stopScanning() { Task { await reliaBLE?.stopScanning() } } - + func clearAllData() { - guard let modelContext = modelContext else { return } - - do { - let discoveryDescriptor = FetchDescriptor(sortBy: []) - let discoveries = try modelContext.fetch(discoveryDescriptor) - discoveries.forEach { modelContext.delete($0) } - - let deviceDescriptor = FetchDescriptor(sortBy: []) - let devices = try modelContext.fetch(deviceDescriptor) - devices.forEach { modelContext.delete($0) } - - try modelContext.save() - } catch { - print("Failed to clear data: \(error)") - } + Task { await deviceStore?.clearAll() } } - - func deleteDiscoveries(_ items: [DiscoveryEvent]) { - items.forEach { modelContext?.delete($0) } - try? modelContext?.save() + + func deleteDiscoveries(ids: [PersistentIdentifier]) { + Task { await deviceStore?.deleteDiscoveries(ids: ids) } } - - func deleteDevices(_ items: [Device]) { - items.forEach { modelContext?.delete($0) } - try? modelContext?.save() + + func deleteDevices(ids: [PersistentIdentifier]) { + Task { await deviceStore?.deleteDevices(ids: ids) } } - + private func parseServices(from input: String) -> [CBUUID]? { let components = input.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } let uuids = components.filter { !$0.isEmpty }.map { CBUUID(string: $0) } - + return uuids.isEmpty ? nil : uuids } -} +} \ No newline at end of file diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift new file mode 100644 index 0000000..075b7b3 --- /dev/null +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift @@ -0,0 +1,129 @@ +// +// DeviceStoreActor.swift +// ReliaBLE Demo +// +// Copyright (c) 2025 Five3 Apps, LLC +// +// 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: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// 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. + +import Foundation +import SwiftData + +import ReliaBLE + +/// Canonical off-main SwiftData persistence for ReliaBLE discovery events. +/// +/// Copy this pattern when building a real app: +/// - Hold `ReliaBLEManager` on the main actor (or inject via SwiftUI environment). +/// - Consume `AsyncStream` surfaces in `.task { for await … }`. +/// - Create the store with ``create(container:)`` so SwiftData's `ModelActor` runs off the main thread. +/// - Route every SwiftData write through this actor; keep reads on `@MainActor` via `@Query`. +actor DeviceStoreActor: ModelActor { + nonisolated let modelExecutor: any ModelExecutor + nonisolated let modelContainer: ModelContainer + + init(modelContainer: ModelContainer) { + let modelContext = ModelContext(modelContainer) + self.modelExecutor = DefaultSerialModelExecutor(modelContext: modelContext) + self.modelContainer = modelContainer + } + + /// Creates a background-isolated store. Call from a nonisolated `async` context — not from + /// `@MainActor` — so `ModelActor` does not inherit main-thread execution. + static nonisolated func create(container: ModelContainer) async -> DeviceStoreActor { + DeviceStoreActor(modelContainer: container) + } + + func insertDiscovery(_ discoveryEvent: PeripheralDiscoveryEvent) { + assertWritesOffMainThread() + + let event = DiscoveryEvent( + peripheralIdentifier: discoveryEvent.id.uuidString, + name: discoveryEvent.name ?? "Unknown", + rssi: discoveryEvent.rssi, + timestamp: Date() + ) + modelContext.insert(event) + try? modelContext.save() + } + + func syncDevices(_ peripherals: [Peripheral]) { + assertWritesOffMainThread() + + do { + let allDevices = try modelContext.fetch(FetchDescriptor()) + for peripheral in peripherals { + if let existingDevice = allDevices.first(where: { $0.id == peripheral.id }) { + existingDevice.name = peripheral.name + existingDevice.lastSeen = peripheral.lastSeen + } else { + let newDevice = Device(id: peripheral.id, name: peripheral.name, lastSeen: Date()) + modelContext.insert(newDevice) + } + } + try modelContext.save() + } catch { + print("Error fetching devices: \(error)") + } + } + + func clearAll() { + assertWritesOffMainThread() + + do { + let discoveries = try modelContext.fetch(FetchDescriptor()) + discoveries.forEach { modelContext.delete($0) } + + let devices = try modelContext.fetch(FetchDescriptor()) + devices.forEach { modelContext.delete($0) } + + try modelContext.save() + } catch { + print("Failed to clear data: \(error)") + } + } + + func deleteDiscoveries(ids: [PersistentIdentifier]) { + assertWritesOffMainThread() + + for id in ids { + if let event = modelContext.model(for: id) as? DiscoveryEvent { + modelContext.delete(event) + } + } + try? modelContext.save() + } + + func deleteDevices(ids: [PersistentIdentifier]) { + assertWritesOffMainThread() + + for id in ids { + if let device = modelContext.model(for: id) as? Device { + modelContext.delete(device) + } + } + try? modelContext.save() + } + + private func assertWritesOffMainThread() { + #if DEBUG + assert(!Thread.isMainThread, "SwiftData writes must run off the main thread") + #endif + } +} \ No newline at end of file diff --git a/docs/plans/demo-background-swiftdata-2026-06-27.md b/docs/plans/demo-background-swiftdata-2026-06-27.md new file mode 100644 index 0000000..1e08992 --- /dev/null +++ b/docs/plans/demo-background-swiftdata-2026-06-27.md @@ -0,0 +1,113 @@ +# Demo: exercise ReliaBLE from a background-actor SwiftData stack: Plan +*Issue #20 · 2026-06-27* + +## Goal + +Refactor the Demo app's SwiftData layer so **all writes** run on a background `ModelActor`, while reads stay on `@MainActor` via `@Query`. The Demo then exercises `ReliaBLEManager` across actor boundaries at PR-time — catching inadvertent `@MainActor` creep in the library — and doubles as a concrete off-main persistence pattern for consumers. No library changes. + +Tracked under parent #10 as a **Preventive Measure** in `docs/investigations/swift6-concurrency-audit-2026-05-13.md` (not a numbered migration step). Depends on the Step 4 `Sendable` façade (#18) and `AsyncStream` surfaces (#12) being in place; both are merged on the current branch. + +## Background (verified pre-change state) + +**Library (ready — no work needed):** +- `ReliaBLEManager` is `public final class ReliaBLEManager: Sendable`, nonisolated, forwarding to internal `BluetoothActor.shared` (`ReliaBLEManager.swift:38`). +- Three event surfaces return fresh per-subscriber `AsyncStream`s: `state`, `peripheralDiscoveries`, `discoveredPeripherals` (`ReliaBLEManager.swift:84–120`). +- Element types `Peripheral`, `PeripheralDiscoveryEvent`, `BluetoothState` are `Sendable` value types — safe to hand across isolation domains from stream loops. +- DocC `Concurrency.md` documents the isolation contract for consumers. + +**Demo consumption (`Demo/ReliaBLE Demo/.../Central/`) — the problem:** +- `CentralView` already consumed streams via three `.task { for await … }` blocks (post-Step 3), but persistence still ran on `@MainActor`. +- `CentralViewModel.insertDiscovery(_:into:)` and `syncDevices(_:into:)` were `@MainActor` methods that took a `ModelContext` and wrote directly inside the stream loops (`CentralViewModel.swift:55–84`; wired from `CentralView.swift:105–114`). +- `clearAllData`, swipe-delete (`deleteDiscoveries` / `deleteDevices`) also used a stored `ModelContext` on the view-model. +- `@Query` in `CentralView` handled reads on main — this part was already correct and stays. +- `ReliaBLE_DemoApp.swift` provides a single shared `ModelContainer` via `.modelContainer(sharedModelContainer)` and injects `ReliaBLEManager` via `@Environment(\.bleManager)`. +- Demo builds with `SWIFT_STRICT_CONCURRENCY = complete` (`project.pbxproj`). + +**SwiftData models (unchanged):** +- `Device` — one row per unique discovered peripheral (keyed by `Peripheral.id`), upserted on each `discoveredPeripherals` tick. +- `DiscoveryEvent` — one row per raw advertisement (`peripheralDiscoveries` event). + +**Out of scope:** library test-target background-actor smoke test (Step 5 / #19); Peripheral tab (no SwiftData). + +## Approach + +Introduce `DeviceStoreActor` — a `ModelActor` that owns every SwiftData write. `CentralView` creates it off the main thread, then runs three concurrent stream loops: state → `@MainActor` view-model; discoveries and peripheral list → store. + +``` +CentralView (@MainActor reads via @Query) + │ + ├─ .task ──► DeviceStoreActor.create(container:) [off-main creation] + │ │ + │ ├─ insertDiscovery / syncDevices / delete* / clearAll + │ └─ modelContext.save() + │ + ├─ ReliaBLEManager.state ──────────► CentralViewModel.updateState (@MainActor) + ├─ ReliaBLEManager.peripheralDiscoveries ──► store.insertDiscovery + └─ ReliaBLEManager.discoveredPeripherals ──► store.syncDevices +``` + +**`DeviceStoreActor` design:** +- Manual `ModelActor` conformance (not the `@ModelActor` macro) with an explicit `init(modelContainer:)` and `nonisolated let modelExecutor` / `modelContainer` — the macro's synthesized initializer is not accessible from outside the type, which blocked environment-key and preview construction. +- `static nonisolated func create(container:) async -> DeviceStoreActor` — **critical**: SwiftData `ModelActor` inherits the thread of its creation context. Initializing on `@MainActor` (e.g. in `App.init` or a synchronous environment default) pins writes to the main thread despite the actor label. The factory must be called from a `nonisolated async` context so the actor actually runs in the background. +- Write methods: `insertDiscovery`, `syncDevices`, `clearAll`, `deleteDiscoveries(ids:)`, `deleteDevices(ids:)`. Swipe-delete passes `PersistentIdentifier` (Sendable) rather than `@Model` class instances. +- `#if DEBUG` `assert(!Thread.isMainThread)` in every write method — acceptance-criteria guard. +- File-level doc comment pointing consumers at this as the canonical off-main persistence pattern. + +**`CentralView` pipeline:** +- One `.task` block: capture `let manager = reliaBLE` (avoids MainActor-isolated environment access inside `TaskGroup` children under strict concurrency), `await DeviceStoreActor.create(container: modelContext.container)`, wire `viewModel.setDependencies`, then `withTaskGroup` running three concurrent `for await` loops. +- `@Environment(\.modelContext)` retained **only** to reach `modelContext.container` for store creation; no direct writes through it. +- `@Query` unchanged for Devices / Discoveries lists. + +**`CentralViewModel` slim-down:** +- Drop `modelContext` storage and all `@MainActor` persistence methods. +- Keep `@MainActor updateState` for `@Observable` UI properties. +- Keep fire-and-forget BLE actions (`authorizeBluetooth`, `startScanning`, `stopScanning`). +- `clearAllData` / delete helpers wrap `Task { await deviceStore?.… }`. + +**Documentation:** inline comment in `DeviceStoreActor.swift` + update `Demo/AGENTS.md` concurrency / app-structure sections. + +## Work Items + +Demo-only; library untouched. Build via XcodeBuildMCP (workspace `ReliaBLE.xcworkspace`, scheme `ReliaBLE Demo`). + +1. **`DeviceStoreActor.swift` — new file.** `actor DeviceStoreActor: ModelActor` under `Central/`. Move write logic from the old `CentralViewModel` persistence methods. Add `create(container:)` factory and debug thread asserts. PBXFileSystemSynchronizedRootGroup auto-includes the file. *(Build may fail until items 2–3 land.)* + +2. **`CentralViewModel.swift` — slim down.** Remove `modelContext`, `insertDiscovery`, `syncDevices`. Change `setDependencies` to `(deviceStore:reliaBLE:)`. Route `clearAllData` / delete through `deviceStore` via `Task { await … }`. Keep `updateState` as the sole `@MainActor` handler. + +3. **`CentralView.swift` — rewire pipeline.** Replace three separate `.task` loops + `onAppear` modelContext wiring with the single `.task` + `withTaskGroup` pattern. Swipe-delete passes `persistentModelID`. Capture `reliaBLE` into a local `manager` before spawning child tasks. + +4. **`Demo/AGENTS.md` — document pattern.** Update App structure and Swift Concurrency sections: `DeviceStoreActor` owns writes; `CentralViewModel` owns UI state + BLE actions; `@Query` owns reads; store created via `create(container:)`. + +5. **Verify.** + - `ReliaBLE Demo` scheme builds clean under `SWIFT_STRICT_CONCURRENCY = complete` (zero warnings). + - Debug asserts fire during scanning (writes off-main). + - UX unchanged: Devices / Discoveries lists update live; Clear All and swipe-delete work. + - No `@MainActor` annotation added to any library public type to make the Demo compile (regression guard). + +## Decisions (resolved at implementation) + +1. **Manual `ModelActor` conformance, not `@ModelActor` macro.** The macro synthesizes an initializer that is not constructible from `EnvironmentKey.defaultValue`, `ReliaBLE_DemoApp` property initializers, or `#Preview` — build error: *"'DeviceStoreActor' cannot be constructed because it has no accessible initializers."* Manual expansion (`modelExecutor` + `modelContainer` + `DefaultSerialModelExecutor`) matches the macro output and exposes a public `init(modelContainer:)`. + +2. **No `@Environment(\.deviceStore)` injection.** App-level `var deviceStore = DeviceStoreActor(modelContainer: sharedModelContainer)` also fails: (a) `sharedModelContainer` is unavailable in a sibling property initializer, and (b) synchronous main-thread creation defeats the off-main goal. Store is created in `CentralView`'s `.task` via `create(container:)` instead. + +3. **Single `.task` + `withTaskGroup` instead of three `.task` blocks.** Guarantees the store exists before stream loops start; runs state / discoveries / peripherals concurrently. Alternative (three independent `.task`s) risks racing on an uninitialized store. + +4. **`let manager = reliaBLE` before `TaskGroup`.** `@Environment(\.bleManager)` is MainActor-isolated; child tasks in the group are not. Local capture of the `Sendable` manager avoids Swift 6 errors. Same pattern applies to any future environment-injected `Sendable` service. + +5. **Deletes via `PersistentIdentifier`, not model instances.** `@Model` classes are not `Sendable`; passing them into `DeviceStoreActor` from `@MainActor` list handlers would violate strict concurrency. `persistentModelID` is `Sendable` and resolves back to the model inside the actor via `modelContext.model(for:)`. + +6. **Docs split: inline comment + `Demo/AGENTS.md`.** Issue asked for README or inline comment; both inline (`DeviceStoreActor.swift` header) and agents doc update were chosen. No standalone `Demo/README.md`. + +## Open Questions + +None blocking. One item noted for future Demo work: +- **Fetch perf on `syncDevices`:** current implementation fetches all `Device` rows per `discoveredPeripherals` tick (inherited from pre-refactor code). Fine for a demo; a production app would index by `id` or maintain an in-actor lookup cache. + +## References + +- Issue #20; parent #10. Related library steps: #12 (AsyncStream), #18 (`Sendable` façade), #19 (strict-concurrency flag / DocC `Concurrency.md`). +- Audit preventive measure: `docs/investigations/swift6-concurrency-audit-2026-05-13.md` — Preventive Measures ("Cross-actor validation in the Demo"), Tracking table. +- SwiftData `ModelActor` creation-context behavior: [massicotte.org/model-actor](https://www.massicotte.org/model-actor/) (ModelActor inherits main-thread execution when created on MainActor). +- Key files: `Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift`, `CentralViewModel.swift`, `CentralView.swift`, `ReliaBLE_DemoApp.swift`, `Demo/AGENTS.md`. +- Library concurrency contract (unchanged): `Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md`. +- Prior step plans: `docs/plans/combine-to-asyncstream-2026-06-18.md` (Step 3 — Demo stream consumption), `docs/plans/manager-sendable-collapse-2026-06-23.md` (Step 4 — `Sendable` façade). \ No newline at end of file From 84f880a00c7b78f0fe292a73281fa714f0275a94 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 27 Jun 2026 15:04:58 -0600 Subject: [PATCH 23/27] Address Copilot PR feedback on DeviceStoreActor (#20) Use Task.detached in create(container:) so ModelActor construction is guaranteed off-main even when called from SwiftUI .task. Use peripheral.lastSeen consistently on new Device inserts. Fix AGENTS.md to describe manual ModelActor conformance, not the @ModelActor macro. --- Demo/AGENTS.md | 2 +- .../ReliaBLE Demo/Central/DeviceStoreActor.swift | 10 ++++++---- docs/plans/demo-background-swiftdata-2026-06-27.md | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Demo/AGENTS.md b/Demo/AGENTS.md index 46e18ab..6e0899e 100644 --- a/Demo/AGENTS.md +++ b/Demo/AGENTS.md @@ -42,7 +42,7 @@ Scheme: **"ReliaBLE Demo"**. `ReliaBLE Demo.xcodeproj` lives at `Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj`. The app is a 3-tab SwiftUI app: -- **Central** (`Central/`) — uses `ReliaBLEManager` (the library) to scan, surfaces discoveries into a SwiftData store. `CentralView` subscribes to the library's `AsyncStream` surfaces in `.task { for await … }` loops; `CentralViewModel` holds UI state and BLE actions only. All SwiftData writes go through `DeviceStoreActor` (`@ModelActor`). State observed via `@Observable`. +- **Central** (`Central/`) — uses `ReliaBLEManager` (the library) to scan, surfaces discoveries into a SwiftData store. `CentralView` subscribes to the library's `AsyncStream` surfaces in `.task { for await … }` loops; `CentralViewModel` holds UI state and BLE actions only. All SwiftData writes go through `DeviceStoreActor` (manual `ModelActor` conformance). State observed via `@Observable`. - **Peripheral** (`Peripheral/`) — uses raw `CBPeripheralManager` directly (not the library — the library doesn't yet expose peripheral mode). This is intentional; the demo shows both sides of BLE even though only the central side flows through ReliaBLE. - **Settings** (`Settings/`) — app-level toggles. diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift index 075b7b3..176f82f 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift @@ -44,10 +44,12 @@ actor DeviceStoreActor: ModelActor { self.modelContainer = modelContainer } - /// Creates a background-isolated store. Call from a nonisolated `async` context — not from - /// `@MainActor` — so `ModelActor` does not inherit main-thread execution. + /// Creates a background-isolated store. Safe to call from `@MainActor` (e.g. SwiftUI `.task`); + /// construction is explicitly detached so `ModelActor` does not inherit main-thread execution. static nonisolated func create(container: ModelContainer) async -> DeviceStoreActor { - DeviceStoreActor(modelContainer: container) + await Task.detached { + DeviceStoreActor(modelContainer: container) + }.value } func insertDiscovery(_ discoveryEvent: PeripheralDiscoveryEvent) { @@ -73,7 +75,7 @@ actor DeviceStoreActor: ModelActor { existingDevice.name = peripheral.name existingDevice.lastSeen = peripheral.lastSeen } else { - let newDevice = Device(id: peripheral.id, name: peripheral.name, lastSeen: Date()) + let newDevice = Device(id: peripheral.id, name: peripheral.name, lastSeen: peripheral.lastSeen) modelContext.insert(newDevice) } } diff --git a/docs/plans/demo-background-swiftdata-2026-06-27.md b/docs/plans/demo-background-swiftdata-2026-06-27.md index 1e08992..49bc2ee 100644 --- a/docs/plans/demo-background-swiftdata-2026-06-27.md +++ b/docs/plans/demo-background-swiftdata-2026-06-27.md @@ -48,7 +48,7 @@ CentralView (@MainActor reads via @Query) **`DeviceStoreActor` design:** - Manual `ModelActor` conformance (not the `@ModelActor` macro) with an explicit `init(modelContainer:)` and `nonisolated let modelExecutor` / `modelContainer` — the macro's synthesized initializer is not accessible from outside the type, which blocked environment-key and preview construction. -- `static nonisolated func create(container:) async -> DeviceStoreActor` — **critical**: SwiftData `ModelActor` inherits the thread of its creation context. Initializing on `@MainActor` (e.g. in `App.init` or a synchronous environment default) pins writes to the main thread despite the actor label. The factory must be called from a `nonisolated async` context so the actor actually runs in the background. +- `static nonisolated func create(container:) async -> DeviceStoreActor` — **critical**: SwiftData `ModelActor` inherits the thread of its creation context. Initializing on `@MainActor` (e.g. in `App.init`, a SwiftUI `.task`, or a synchronous environment default) pins writes to the main thread despite the actor label. The factory wraps construction in `Task.detached` so callers can safely invoke it from any context. - Write methods: `insertDiscovery`, `syncDevices`, `clearAll`, `deleteDiscoveries(ids:)`, `deleteDevices(ids:)`. Swipe-delete passes `PersistentIdentifier` (Sendable) rather than `@Model` class instances. - `#if DEBUG` `assert(!Thread.isMainThread)` in every write method — acceptance-criteria guard. - File-level doc comment pointing consumers at this as the canonical off-main persistence pattern. From baa089633f74b288d66988a6cc179611abfae3df Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 27 Jun 2026 18:23:13 -0600 Subject: [PATCH 24/27] Address PR #29 review: bound discovery buffer, fix init race, order delegate callbacks, suspend authorize - peripheralDiscoveriesStream: bound buffer to .bufferingNewest(10_000) (~10MB cap) instead of unbounded, so a stalled subscriber drops oldest events rather than growing memory without limit. - ensureInitialized(log:): idempotent actor setup that every public ReliaBLEManager entry point funnels through, closing the init-vs-immediate-call race. It also (re)creates the central manager on any entry when already .allowedAlways, so operations work after out-of-band authorization while preserving the lazy-permission contract. - Ordered delegate handling: BluetoothDelegateShim yields each callback into a single AsyncStream drained by one consumer task, preserving CoreBluetooth's serial callback order (replaces per-callback Tasks that could reorder). - Suspending authorize: .notDetermined now suspends until centralManagerDidUpdateState resolves the decision; cancellation is wired via withTaskCancellationHandler in the nonisolated ReliaBLEManager facade, keeping the actor method clear of a construct the Swift 6.1 region-isolation checker cannot analyze. - connect(to:) throws PeripheralError.bluetoothUnavailable (new case; enum now Equatable) when no central manager exists, instead of silently returning. - Expanded the FR-8.5 TODO documenting the same-name peripheral identity collision. - Tests updated for the new behaviors (cancellable authorize, broadened connect error). Co-Authored-By: Claude Opus 4.8 --- Sources/ReliaBLE/BluetoothActor.swift | 216 +++++++++++++++--- Sources/ReliaBLE/Models/PeripheralError.swift | 9 +- Sources/ReliaBLE/ReliaBLEManager.swift | 47 ++-- .../ReliaBLETests/ReliaBLEManagerTests.swift | 51 ++++- 4 files changed, 270 insertions(+), 53 deletions(-) diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index ec10a28..7132303 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -45,6 +45,18 @@ private struct DiscoveryPayload: @unchecked Sendable { let rssi: Int } +/// A single CoreBluetooth delegate callback, carried in delivery order across the nonisolated +/// delegate-queue → ``BluetoothActor`` hop. +/// +/// CoreBluetooth invokes delegate methods serially on its dispatch queue. ``BluetoothDelegateShim`` +/// yields one of these per callback into a single `AsyncStream`, and ``BluetoothActor`` drains them +/// with a single consumer so the original callback ordering is preserved — independent per-callback +/// `Task`s could be reordered before reaching the actor. +private enum DelegateEvent: Sendable { + case stateUpdate + case discovered(DiscoveryPayload) +} + // MARK: - BluetoothActor /// Process-wide global actor that serializes all CoreBluetooth interactions. @@ -68,7 +80,19 @@ actor BluetoothActor { var centralManager: CBCentralManager? private var delegateShim: BluetoothDelegateShim? - + + /// Drains delegate callbacks in order. Lives for the process lifetime of the singleton actor. + private var delegateEventTask: Task? + + /// Tracks one-time actor setup so ``ensureInitialized(log:)`` is idempotent across the many + /// `ReliaBLEManager` façades that may share this process-wide actor. + private var isInitialized = false + + /// Continuations for in-flight ``authorize()`` calls awaiting an authorization decision, keyed by a + /// per-call id so a cancelled call can resume just its own continuation. All pending continuations are + /// resumed together once `CBCentralManager.authorization` resolves away from `.notDetermined`. + private var authorizationContinuations: [UUID: CheckedContinuation] = [:] + /// The current Bluetooth state. var currentBluetoothState: BluetoothState = .unknown @@ -110,14 +134,26 @@ actor BluetoothActor { } } + /// Upper bound on the number of discovery events buffered for a single subscriber. + /// + /// A `PeripheralDiscoveryEvent` is small: a `UUID`, an optional name, an `Int` RSSI, and a typed + /// ``AdvertisementData`` snapshot whose backing advertisement payload is capped by the BLE spec at a few hundred + /// bytes — comfortably under ~1 KB per event including Swift/Foundation overhead. Bounding the buffer at 10,000 + /// events caps a stalled or abandoned subscriber at roughly ~10 MB rather than letting it grow without limit, + /// while staying far above any realistic in-flight backlog. + static let discoveryBufferLimit = 10_000 + /// Returns a fresh `AsyncStream` of peripheral discovery events for a single subscriber. /// /// Unlike ``stateStream()`` and ``discoveredPeripheralsStream()`` this feed does **not** replay /// a value on subscription; a subscriber only receives advertisements observed after it /// registers. An advertisement that arrives in the narrow window between stream creation and /// continuation registration is missed — accepted for a lightweight advertisements feed. + /// + /// The buffer is bounded with `.bufferingNewest(`` discoveryBufferLimit ``)`: a slow or abandoned subscriber + /// drops the oldest pending advertisements rather than growing memory without bound. nonisolated func peripheralDiscoveriesStream() -> AsyncStream { - AsyncStream { continuation in + AsyncStream(bufferingPolicy: .bufferingNewest(BluetoothActor.discoveryBufferLimit)) { continuation in Task { await BluetoothActor.shared.register(discoveryContinuation: continuation) } } } @@ -189,15 +225,36 @@ actor BluetoothActor { self.log = log } - /// Configures the actor with a logger, conditionally sets up the central manager if - /// Bluetooth is already authorized, then broadcasts the initial state. Called once from - /// `ReliaBLEManager.init` via a fire-and-forget `Task`. - func initialize(log: LoggingService) { - configure(log: log) - if CBCentralManager.authorization == .allowedAlways { + /// Performs idempotent actor setup, funneled through by every public ``ReliaBLEManager`` entry point + /// before it acts — so an operation invoked immediately after `init` (whose setup runs in a + /// fire-and-forget `Task`) cannot race ahead of setup and silently no-op. + /// + /// The logger is configured exactly once. On *every* call this also creates the central manager if + /// Bluetooth is currently authorized (`.allowedAlways`) and one does not already exist — so an + /// operation issued after authorization is granted out-of-band (via Settings, app lifecycle, or + /// another owner) still finds a live manager instead of being permanently gated by the first call's + /// authorization status. + /// + /// Creating the central manager remains gated on existing `.allowedAlways` authorization, preserving + /// the lazy-permission contract: the iOS prompt only appears when the integrating app calls + /// ``ReliaBLEManager/authorizeBluetooth()``. The initial state is broadcast on first setup and + /// whenever the manager is created, but not on every redundant call. + func ensureInitialized(log: LoggingService) { + let firstInitialization = !isInitialized + if firstInitialization { + isInitialized = true + configure(log: log) + } + + var createdManager = false + if centralManager == nil, CBCentralManager.authorization == .allowedAlways { setupCentralManager() + createdManager = true + } + + if firstInitialization || createdManager { + updateState() } - updateState() } // MARK: - Central Manager Setup @@ -207,21 +264,61 @@ actor BluetoothActor { log?.info("Initializing CBCentralManager") - let shim = BluetoothDelegateShim() + // A single `AsyncStream` carries delegate callbacks in CoreBluetooth's delivery order; the lone + // consumer task below drains them so ordering is preserved end-to-end. The buffer is intentionally + // unbounded: state-change callbacks must never be dropped (unlike the public advertisements feed), + // and `process(_:)` is lightweight, so the actor keeps pace with CoreBluetooth's serial callback + // rate in practice. + let (events, continuation) = AsyncStream.makeStream( + of: DelegateEvent.self, + bufferingPolicy: .unbounded + ) + let shim = BluetoothDelegateShim(eventContinuation: continuation) delegateShim = shim // Use CBCentralManagerFactory for consistency between normal and test targets. // `forceMock: true` is load-bearing for the ReliaBLEMock test target — do not remove. centralManager = CBCentralManagerFactory.instance(delegate: shim, queue: centralManagerQueue, options: nil, forceMock: true) + + delegateEventTask = Task { [weak self] in + for await event in events { + await self?.process(event) + } + } + } + + /// Drains a single delegate event on the actor, preserving CoreBluetooth's callback order. + private func process(_ event: DelegateEvent) { + switch event { + case .stateUpdate: + handleCentralManagerStateUpdate() + case .discovered(let payload): + handlePeripheralDiscovered( + payload.peripheral, + advertisementData: payload.advertisementData, + rssi: payload.rssi + ) + } } // MARK: - Authorization - func authorize() throws { + /// Performs the authorization decision for a single ``ReliaBLEManager/authorizeBluetooth()`` call. + /// + /// For undetermined authorization this creates the central manager (triggering the iOS prompt) and + /// suspends until the decision arrives via `centralManagerDidUpdateState`, so a successful return + /// means Bluetooth is authorized. The caller-supplied `id` lets ``ReliaBLEManager`` cancel this + /// specific wait via ``cancelAuthorizationContinuation(_:)``. + /// + /// The `withTaskCancellationHandler` that wires cancellation lives in the nonisolated + /// ``ReliaBLEManager`` façade, not here, to keep this actor-isolated method free of a construct the + /// region-based isolation checker cannot yet analyze. + func authorize(id: UUID) async throws { log?.info("Authorizing bluetooth") switch CBCentralManager.authorization { case .notDetermined: setupCentralManager() + try await suspendForAuthorizationDecision(id: id) case .denied: throw AuthorizationError.denied case .restricted: @@ -233,6 +330,55 @@ actor BluetoothActor { } } + /// Suspends until the pending authorization decision resolves (or the calling task is cancelled), + /// storing the continuation under `id`. Kept as its own actor-isolated method so the surrounding + /// `withTaskCancellationHandler` operation closure stays simple for the region-isolation checker. + private func suspendForAuthorizationDecision(id: UUID) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + // The task may already be cancelled by the time this job runs on the actor. + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + authorizationContinuations[id] = continuation + } + } + + /// Resumes a single pending authorization continuation with a `CancellationError`, if still pending. + /// Invoked from ``ReliaBLEManager``'s cancellation handler. + func cancelAuthorizationContinuation(_ id: UUID) { + authorizationContinuations.removeValue(forKey: id)?.resume(throwing: CancellationError()) + } + + /// Resolves any ``authorize()`` calls suspended on an authorization decision. + /// + /// Called after every `centralManagerDidUpdateState`, since CoreBluetooth surfaces an + /// authorization change as a state update. While the decision is still pending + /// (`.notDetermined`) the continuations remain suspended. + private func resolvePendingAuthorization() { + guard !authorizationContinuations.isEmpty else { return } + + let result: Result + switch CBCentralManager.authorization { + case .notDetermined: + return // Still awaiting the user's decision. + case .allowedAlways: + result = .success(()) + case .denied: + result = .failure(AuthorizationError.denied) + case .restricted: + result = .failure(AuthorizationError.restricted) + @unknown default: + result = .failure(AuthorizationError.unknown) + } + + let pending = authorizationContinuations + authorizationContinuations.removeAll() + for continuation in pending.values { + continuation.resume(with: result) + } + } + // MARK: - Scanning func startScanning(services: sending [CBUUID]? = nil) { @@ -337,6 +483,7 @@ actor BluetoothActor { } updateState() + resolvePendingAuthorization() } func handlePeripheralDiscovered( @@ -356,7 +503,18 @@ actor BluetoothActor { to: discoveryContinuations ) - // TODO: FR-8.5: Unique Identifier from Manufacturing Data — connect to id once implemented + // Derive the app-facing `id` from the advertised name, falling back to the local name and + // finally the CoreBluetooth identifier string. + // + // TODO: FR-8.5 — Unique Identifier from Manufacturing Data. + // KNOWN LIMITATION: advertised names are not unique. Two distinct physical devices that + // advertise the same name resolve to the same `identifier` here, so they collapse into a + // single `discoveredPeripherals` entry and a single `cbPeripherals` slot — the later + // discovery overwrites the earlier device's live `CBPeripheral`, so `connect(id:)` may target + // whichever was seen last. FR-8.5 will replace this with a stable identity derived from + // manufacturing data; until then the dedup key is best-effort. The `cbIdentifier` fallback + // below only rescues a *single* device whose advertised name changes, not the same-name + // collision between *different* devices. let identifier = cbPeripheral.name ?? advertisement.localName ?? cbPeripheral.identifier.uuidString @@ -444,15 +602,15 @@ actor BluetoothActor { /// - Note: This currently only fires the connection request. The full connection lifecycle /// (didConnect/didDisconnect handling and a connection-state surface) is deferred to a later release. func connect(id: String) throws { - guard let cbPeripheral = cbPeripherals[id] else { - throw PeripheralError.notFound - } - guard let centralManager else { - // Mirrors the start/stopScanning convention: no central manager means there is nothing to act on. - // In practice unreachable — the registry is only populated while a central manager exists. + // No central manager means Bluetooth was never set up (e.g. not authorized). Surface this + // rather than silently succeeding, mirroring the throwing `notFound` path below. log?.warn(tags: [.peripheral(id)], "Attempted to connect without a central manager") - return + throw PeripheralError.bluetoothUnavailable + } + + guard let cbPeripheral = cbPeripherals[id] else { + throw PeripheralError.notFound } centralManager.connect(cbPeripheral, options: nil) @@ -469,8 +627,18 @@ actor BluetoothActor { /// process-lifetime singleton. final class BluetoothDelegateShim: NSObject, CBCentralManagerDelegate { + /// Sink for delegate callbacks, drained in order by ``BluetoothActor``'s consumer task. + private let eventContinuation: AsyncStream.Continuation + + fileprivate init(eventContinuation: AsyncStream.Continuation) { + self.eventContinuation = eventContinuation + super.init() + } + func centralManagerDidUpdateState(_ central: CBCentralManager) { - Task { @BluetoothActor in await BluetoothActor.shared.handleCentralManagerStateUpdate() } + // Yielding is synchronous and thread-safe; ordering is preserved because CoreBluetooth + // invokes delegate methods serially on its dispatch queue. + eventContinuation.yield(.stateUpdate) } func centralManager( @@ -483,12 +651,6 @@ final class BluetoothDelegateShim: NSObject, CBCentralManagerDelegate { // single-purpose payload. They are extracted into Sendable types (Peripheral / AdvertisementData) inside // the actor. let payload = DiscoveryPayload(peripheral: peripheral, advertisementData: advertisementData, rssi: RSSI.intValue) - Task { @BluetoothActor in - await BluetoothActor.shared.handlePeripheralDiscovered( - payload.peripheral, - advertisementData: payload.advertisementData, - rssi: payload.rssi - ) - } + eventContinuation.yield(.discovered(payload)) } } diff --git a/Sources/ReliaBLE/Models/PeripheralError.swift b/Sources/ReliaBLE/Models/PeripheralError.swift index 8dfa6b4..dceff7b 100644 --- a/Sources/ReliaBLE/Models/PeripheralError.swift +++ b/Sources/ReliaBLE/Models/PeripheralError.swift @@ -26,7 +26,7 @@ /// Errors thrown by peripheral operations such as ``ReliaBLEManager/connect(to:)``. -public enum PeripheralError: Error, Sendable { +public enum PeripheralError: Error, Sendable, Equatable { /// The peripheral is no longer known to the library. /// /// A ``Peripheral`` is a value snapshot captured at discovery time. The live CoreBluetooth peripheral it refers to @@ -34,4 +34,11 @@ public enum PeripheralError: Error, Sendable { /// example, after Bluetooth reset) the snapshot is stale and operations that require the live peripheral throw /// this error. case notFound + + /// Bluetooth is unavailable, so the operation could not be performed. + /// + /// Thrown when a peripheral operation is attempted before the underlying `CBCentralManager` exists — for example, + /// because Bluetooth has not been authorized yet. Call ``ReliaBLEManager/authorizeBluetooth()`` and wait for a + /// ready state before retrying. + case bluetoothUnavailable } diff --git a/Sources/ReliaBLE/ReliaBLEManager.swift b/Sources/ReliaBLE/ReliaBLEManager.swift index d52d018..4095405 100644 --- a/Sources/ReliaBLE/ReliaBLEManager.swift +++ b/Sources/ReliaBLE/ReliaBLEManager.swift @@ -54,19 +54,12 @@ public final class ReliaBLEManager: Sendable { log = loggingService - // `init` stays synchronous and dispatches actor setup via a fire-and-forget `Task` - // rather than awaiting it. The initial `setupCentralManager()` (if already - // `.allowedAlways`) and `updateState()` land on the actor's queue on the next - // run-loop turn. - // - // This does mean a caller that immediately awaits `startScanning()`/`stopScanning()` - // could in theory have that call's actor job ordered ahead of `initialize`'s, since - // Swift actors don't guarantee FIFO ordering across jobs enqueued from separate - // top-level Tasks. Both methods already guard on `centralManager == nil` and no-op - // with a log warning in that case, so the worst case is a missed scan start rather - // than a crash. Revisit if this race proves observable in practice — e.g. by making - // `init` `async` or exposing an explicit `await manager.ready()`. - Task { await BluetoothActor.shared.initialize(log: loggingService) } + // `init` stays synchronous and kicks off one-time actor setup via a fire-and-forget `Task` + // rather than awaiting it, so the initializer never blocks. To prevent an operation invoked + // immediately after `init` from racing ahead of that setup, every public entry point funnels + // through `ensureInitialized(log:)` (which is idempotent) before acting — so this eager call + // is an optimization, not a correctness requirement. + Task { await BluetoothActor.shared.ensureInitialized(log: loggingService) } } // MARK: - State @@ -92,12 +85,26 @@ public final class ReliaBLEManager: Sendable { get async { await BluetoothActor.shared.currentBluetoothState } } - /// Requests authorization to use Bluetooth. This method will throw an error if the user has denied or restricted - /// Bluetooth access. + /// Requests authorization to use Bluetooth, presenting the iOS permission prompt when authorization has not yet + /// been determined. /// - /// - Throws: An ``AuthorizationError`` error if the user has denied or restricted Bluetooth access. + /// When authorization is undetermined this call **suspends until the user responds**, returning normally only + /// once access is granted. If the user denies the prompt (or access is already denied/restricted) it throws. A + /// successful return can therefore be relied upon to mean Bluetooth is authorized. + /// + /// - Throws: An ``AuthorizationError`` if the user has denied or restricted Bluetooth access. public func authorizeBluetooth() async throws { - try await BluetoothActor.shared.authorize() + await BluetoothActor.shared.ensureInitialized(log: log) + + // Own the cancellation wiring here, in the nonisolated façade. When authorization is + // undetermined the actor suspends until the decision resolves; cancelling the calling task + // unblocks that wait with a `CancellationError` rather than hanging indefinitely. + let id = UUID() + try await withTaskCancellationHandler { + try await BluetoothActor.shared.authorize(id: id) + } onCancel: { + Task { await BluetoothActor.shared.cancelAuthorizationContinuation(id) } + } } // MARK: - Scanning @@ -128,11 +135,13 @@ public final class ReliaBLEManager: Sendable { /// - Note: If Bluetooth is not authorized or powered on, this method will not start scanning. It is the caller's /// responsibility to ensure that Bluetooth is authorized and powered on before calling this method. public func startScanning(services: sending [CBUUID]? = nil) async { + await BluetoothActor.shared.ensureInitialized(log: log) await BluetoothActor.shared.startScanning(services: services) } /// Stops scanning for peripheral devices. public func stopScanning() async { + await BluetoothActor.shared.ensureInitialized(log: log) await BluetoothActor.shared.stopScanning() } @@ -145,11 +154,13 @@ public final class ReliaBLEManager: Sendable { /// /// - Parameter peripheral: A peripheral previously delivered via ``discoveredPeripherals``. /// - Throws: ``PeripheralError/notFound`` if the peripheral's live reference has been invalidated (a stale - /// snapshot). + /// snapshot), or ``PeripheralError/bluetoothUnavailable`` if Bluetooth has not been set up (for example, not + /// yet authorized). /// /// - Note: This currently only initiates the connection request. The full connection lifecycle is deferred to a /// later release. public func connect(to peripheral: Peripheral) async throws { + await BluetoothActor.shared.ensureInitialized(log: log) try await BluetoothActor.shared.connect(id: peripheral.id) } } diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index e005610..cbec972 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -40,11 +40,19 @@ import Testing _ = manager.state _ = manager.peripheralDiscoveries _ = manager.discoveredPeripherals - try? await manager.authorizeBluetooth() await manager.startScanning() await manager.startScanning(services: []) await manager.stopScanning() try? await manager.connect(to: Peripheral(id: "unused")) + + // `authorizeBluetooth()` suspends until the authorization decision resolves; in the mock that + // never happens, so drive it from a child task and cancel after a beat. This still exercises + // the member for the Sendable proof while relying on authorize()'s cancellation handling to + // avoid hanging. + let authTask = Task { try? await manager.authorizeBluetooth() } + try? await Task.sleep(nanoseconds: 100_000_000) + authTask.cancel() + _ = await authTask.value }.value } @@ -58,17 +66,20 @@ import Testing #expect(capturedId == "sendable-id") } -@Test func connectToUnknownPeripheralThrowsNotFound() async throws { +@Test func connectToUnknownPeripheralThrows() async throws { let manager = ReliaBLEManager() let staleSnapshot = Peripheral(id: "never-discovered") + // Connecting to a peripheral that was never discovered must throw. Which `PeripheralError` is + // thrown depends on whether a central manager exists in the shared actor at the time: `.notFound` + // when it does (the id is simply not in the live registry) or `.bluetoothUnavailable` when it + // does not (Bluetooth was never set up — the mock reports `.notDetermined` authorization by + // default). Either is a correct "cannot connect to an unknown peripheral" outcome. do { try await manager.connect(to: staleSnapshot) - #expect(false, "Expected PeripheralError.notFound") - } catch PeripheralError.notFound { - // expected - } catch { - #expect(false, "Expected PeripheralError.notFound, got \(error)") + Issue.record("Expected connect(to:) to throw for an unknown peripheral") + } catch let error as PeripheralError { + #expect(error == .notFound || error == .bluetoothUnavailable) } } @@ -121,6 +132,32 @@ import Testing #expect(event == nil) } +@Test func discoveredPeripheralsReplaysCurrentListOnSubscribe() async throws { + let manager = ReliaBLEManager() + + // Unlike `peripheralDiscoveries`, the `discoveredPeripherals` feed replays the current + // (possibly empty) list as its first element on subscription, mirroring `state`. The replay + // proves a value is delivered without waiting for a change broadcast. + var subscriber = manager.discoveredPeripherals.makeAsyncIterator() + let replay = await subscriber.next() + + #expect(replay != nil) +} + +@Test func authorizeCanBeCancelledWhileAwaitingDecision() async throws { + let manager = ReliaBLEManager() + + // With the mock's default (undetermined) authorization, `authorizeBluetooth()` suspends awaiting + // the user's decision. Cancelling the task must unblock the suspension instead of hanging forever. + let task = Task { try await manager.authorizeBluetooth() } + try? await Task.sleep(nanoseconds: 100_000_000) + task.cancel() + + // We only assert that the call resolves (it throws on cancel, or already returned if authorized) — + // i.e. that it does not hang. + _ = await task.result +} + /// Returns the first event from `stream`, or `nil` if none arrives within `nanoseconds`. private func firstEvent( from stream: AsyncStream, From daa5601039124f0f92aaf558beecddff64580640 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 27 Jun 2026 18:40:02 -0600 Subject: [PATCH 25/27] Update DocC for suspending authorize, connect error, and bounded discoveries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GettingStarted: authorizeBluetooth() now suspends until the user responds and returns only when authorized (and is cancellable) — document the new behavior in place of the old "no-op" note. - GettingStarted: the connect(to:) example now also handles PeripheralError.bluetoothUnavailable. - GettingStarted & Concurrency: note that the peripheralDiscoveries feed is bounded (drops the oldest pending advertisements under backpressure). Co-Authored-By: Claude Opus 4.8 --- Sources/ReliaBLE/Documentation.docc/GettingStarted.md | 9 +++++++-- .../ReliaBLE/Documentation.docc/Topics/Concurrency.md | 4 +++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md index a690f4a..8cc8079 100644 --- a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md +++ b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md @@ -69,7 +69,9 @@ iOS requires permission from the user for BLE access. To set this up in your pro ``` The current state is replayed as the stream's first element, so a new subscriber immediately observes the latest state. -Note: The authorization prompt will only appear once. It is safe to call `ReliaBLEManager.authorizeBluetooth()` multiple times. If the user already granted permission it will be a no-op. If the user denies permission, they'll need to enable it manually through the Settings app. +Note: When authorization has not yet been determined, `ReliaBLEManager.authorizeBluetooth()` presents the system prompt and **suspends until the user responds** — it returns normally only once access is granted, and throws ``AuthorizationError`` if the user denies or access is restricted. A successful return therefore means Bluetooth is authorized. Cancelling the calling task unblocks the suspension with a `CancellationError`. + +The prompt only appears once, and it is safe to call the method multiple times: if the user already granted permission the call returns immediately; if they denied it, they'll need to re-enable access through the Settings app. ## Scanning for Peripherals @@ -125,7 +127,7 @@ While scanning, ReliaBLE surfaces results two ways: - ``ReliaBLEManager/peripheralDiscoveries`` emits a lightweight ``PeripheralDiscoveryEvent`` for every advertisement received — useful when you need to process individual advertisement packets. - ``ReliaBLEManager/discoveredPeripherals`` emits the current de-duplicated list of ``Peripheral`` values each time it changes. -Both are `AsyncStream`s. Each property access returns a *fresh, independent* stream, so multiple subscribers are supported by design — consume each with `for await`, typically inside a SwiftUI `.task { … }` (which cancels the loop automatically when the view disappears). ``ReliaBLEManager/state`` and ``ReliaBLEManager/discoveredPeripherals`` replay their latest value to every new subscriber; ``ReliaBLEManager/peripheralDiscoveries`` does **not** replay, so subscribe before you start scanning to avoid missing early advertisements. +Both are `AsyncStream`s. Each property access returns a *fresh, independent* stream, so multiple subscribers are supported by design — consume each with `for await`, typically inside a SwiftUI `.task { … }` (which cancels the loop automatically when the view disappears). ``ReliaBLEManager/state`` and ``ReliaBLEManager/discoveredPeripherals`` replay their latest value to every new subscriber; ``ReliaBLEManager/peripheralDiscoveries`` does **not** replay, so subscribe before you start scanning to avoid missing early advertisements. The discoveries feed is also bounded, so a subscriber that consumes slower than advertisements arrive drops the oldest pending events rather than growing memory without bound. A ``Peripheral`` is an immutable, `Sendable` value snapshot: it carries the peripheral's ``Peripheral/id``, ``Peripheral/name``, ``Peripheral/rssi``, ``Peripheral/lastSeen``, and a strongly-typed ``AdvertisementData`` rather than a raw `[String: Any]` dictionary. Because it is a value type, it is safe to hand directly to your UI. @@ -149,6 +151,9 @@ do { } catch PeripheralError.notFound { // The snapshot is stale — its underlying peripheral reference was invalidated // (for example, after a Bluetooth reset). Re-scan to rediscover it. +} catch PeripheralError.bluetoothUnavailable { + // Bluetooth has not been set up yet (for example, not authorized). Authorize and wait + // for the `.ready` state before retrying. } ``` diff --git a/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md index afaaa53..1745eef 100644 --- a/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md +++ b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md @@ -71,7 +71,9 @@ Replay semantics differ per stream: - ``ReliaBLEManager/discoveredPeripherals`` — replays the **latest** value to new subscribers (`.bufferingNewest(1)`). - ``ReliaBLEManager/peripheralDiscoveries`` — does **not** replay; a new - subscriber only receives discoveries that occur after it begins iterating. + subscriber only receives discoveries that occur after it begins iterating. Its + buffer is bounded (`.bufferingNewest`), so a slow subscriber drops the oldest + pending advertisements rather than growing memory without bound. Because each call returns an independent stream, multiple parts of your app can observe the same surface concurrently without interfering with one another. From a0f23d6f93f87fc6548aad8870ccf2e99e6f9319 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 27 Jun 2026 19:26:55 -0600 Subject: [PATCH 26/27] Fix demo crash when deleting SwiftData discoveries on device Route store writes through Task.detached so MainActor-inherited tasks do not run SwiftData mutations on the main thread. Delete by fetching in the actor's ModelContext instead of model(for:) with IDs from @Query. --- .../Central/CentralViewModel.swift | 15 ++++++++++--- .../Central/DeviceStoreActor.swift | 22 ++++++++++++++----- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift index 65b8862..c23abd9 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift @@ -66,15 +66,24 @@ import ReliaBLE } func clearAllData() { - Task { await deviceStore?.clearAll() } + guard let deviceStore else { return } + Task.detached { + await deviceStore.clearAll() + } } func deleteDiscoveries(ids: [PersistentIdentifier]) { - Task { await deviceStore?.deleteDiscoveries(ids: ids) } + guard let deviceStore else { return } + Task.detached { + await deviceStore.deleteDiscoveries(ids: ids) + } } func deleteDevices(ids: [PersistentIdentifier]) { - Task { await deviceStore?.deleteDevices(ids: ids) } + guard let deviceStore else { return } + Task.detached { + await deviceStore.deleteDevices(ids: ids) + } } private func parseServices(from input: String) -> [CBUUID]? { diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift index 176f82f..5994f9f 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift @@ -103,24 +103,34 @@ actor DeviceStoreActor: ModelActor { func deleteDiscoveries(ids: [PersistentIdentifier]) { assertWritesOffMainThread() + guard !ids.isEmpty else { return } - for id in ids { - if let event = modelContext.model(for: id) as? DiscoveryEvent { + let idSet = Set(ids) + do { + let events = try modelContext.fetch(FetchDescriptor()) + for event in events where idSet.contains(event.persistentModelID) { modelContext.delete(event) } + try modelContext.save() + } catch { + print("Failed to delete discoveries: \(error)") } - try? modelContext.save() } func deleteDevices(ids: [PersistentIdentifier]) { assertWritesOffMainThread() + guard !ids.isEmpty else { return } - for id in ids { - if let device = modelContext.model(for: id) as? Device { + let idSet = Set(ids) + do { + let devices = try modelContext.fetch(FetchDescriptor()) + for device in devices where idSet.contains(device.persistentModelID) { modelContext.delete(device) } + try modelContext.save() + } catch { + print("Failed to delete devices: \(error)") } - try? modelContext.save() } private func assertWritesOffMainThread() { From e40bb7d752a37f13e97740f77cf8ba749e04d6b8 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Sat, 27 Jun 2026 22:57:35 -0600 Subject: [PATCH 27/27] [demo] Lazily create CBPeripheralManager only when starting advertising Previously, PeripheralManager unconditionally instantiated CBPeripheralManager in init(), causing the system Bluetooth permission prompt on a fresh app launch. Move creation into startAdvertising() so the prompt only appears when the user explicitly chooses to use the peripheral advertising feature. This prevents the demo from appearing to trigger authorization automatically (via the library or otherwise). Also update the Start button enable logic to allow activation while the manager is still uninitialized. --- .../Peripheral/PeripheralManager.swift | 35 +++++++++++++------ .../Peripheral/PeripheralView.swift | 2 +- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralManager.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralManager.swift index 7b11d4b..d0b728a 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralManager.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralManager.swift @@ -30,25 +30,31 @@ import SwiftUI @Observable class PeripheralManager: NSObject, CBPeripheralManagerDelegate { var state: CBManagerState = .unknown var isAdvertising: Bool = false - private var peripheralManager: CBPeripheralManager! + private var peripheralManager: CBPeripheralManager? private var peripheralName: String = "" private var serviceUUID: CBUUID? - override init() { - super.init() - peripheralManager = CBPeripheralManager(delegate: self, queue: nil) - } - func startAdvertising(name: String, serviceUUID: CBUUID) { self.peripheralName = name self.serviceUUID = serviceUUID - peripheralManager.removeAllServices() + + if peripheralManager == nil { + // Create the manager only at the point the user requests advertising. + // This avoids triggering a Bluetooth permission prompt on app launch. + // If authorization is notDetermined, this will prompt the user. + peripheralManager = CBPeripheralManager(delegate: self, queue: nil) + // Actual advertising setup happens in peripheralManagerDidUpdateState once powered on. + return + } + + guard peripheralManager?.state == .poweredOn else { return } + peripheralManager?.removeAllServices() let service = CBMutableService(type: serviceUUID, primary: true) - peripheralManager.add(service) + peripheralManager?.add(service) } func stopAdvertising() { - peripheralManager.stopAdvertising() + peripheralManager?.stopAdvertising() isAdvertising = false } @@ -56,6 +62,15 @@ import SwiftUI state = peripheral.state if state != .poweredOn { isAdvertising = false + return + } + + // If the user requested advertising before or while the manager was initializing, + // proceed with adding the service now that we are powered on. + if let uuid = serviceUUID, !isAdvertising { + peripheral.removeAllServices() + let service = CBMutableService(type: uuid, primary: true) + peripheral.add(service) } } @@ -68,7 +83,7 @@ import SwiftUI CBAdvertisementDataLocalNameKey: peripheralName, CBAdvertisementDataServiceUUIDsKey: [serviceUUID!] ] - peripheralManager.startAdvertising(advertisementData) + peripheralManager?.startAdvertising(advertisementData) } func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) { diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralView.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralView.swift index f330325..38e9fe6 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralView.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralView.swift @@ -97,7 +97,7 @@ struct PeripheralView: View { peripheralManager.startAdvertising(name: peripheralName, serviceUUID: uuid) } } - .disabled(!isValid || peripheralManager.state != .poweredOn || peripheralManager.isAdvertising) + .disabled(!isValid || peripheralManager.isAdvertising || (peripheralManager.state != .poweredOn && peripheralManager.state != .unknown)) Button("Stop Advertising") { peripheralManager.stopAdvertising() }