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/.xcodebuildmcp/config.yaml b/.xcodebuildmcp/config.yaml new file mode 100644 index 0000000..f69820f --- /dev/null +++ b/.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/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..101af68 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# 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. + +### 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 +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/AGENTS.md b/Demo/AGENTS.md new file mode 100644 index 0000000..6e0899e --- /dev/null +++ b/Demo/AGENTS.md @@ -0,0 +1,71 @@ +# 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. `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. + +### 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. `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 + +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` 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 + +- 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/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj b/Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj index d41845c..1d5a954 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; @@ -338,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; }; @@ -378,6 +382,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; @@ -392,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; }; @@ -406,7 +414,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 +431,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 +458,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 +475,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 +499,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 +522,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 +544,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 +566,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 @@ () - - private var modelContext: ModelContext? +@Observable class CentralViewModel { + var currentState: BluetoothState = .unknown + var servicesInput = "" + + 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 - - 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)") - } - } - .store(in: &cancellables) + // MARK: - Stream Handlers + // + // 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 } - + 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() { - 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)") + guard let deviceStore else { return } + Task.detached { + await deviceStore.clearAll() } } - - func deleteDiscoveries(_ items: [DiscoveryEvent]) { - items.forEach { modelContext?.delete($0) } - try? modelContext?.save() + + func deleteDiscoveries(ids: [PersistentIdentifier]) { + guard let deviceStore else { return } + Task.detached { + await deviceStore.deleteDiscoveries(ids: ids) + } } - - func deleteDevices(_ items: [Device]) { - items.forEach { modelContext?.delete($0) } - try? modelContext?.save() + + func deleteDevices(ids: [PersistentIdentifier]) { + guard let deviceStore else { return } + Task.detached { + 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..5994f9f --- /dev/null +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/DeviceStoreActor.swift @@ -0,0 +1,141 @@ +// +// 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. 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 { + await Task.detached { + DeviceStoreActor(modelContainer: container) + }.value + } + + 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: peripheral.lastSeen) + 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() + guard !ids.isEmpty else { return } + + 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)") + } + } + + func deleteDevices(ids: [PersistentIdentifier]) { + assertWritesOffMainThread() + guard !ids.isEmpty else { return } + + 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)") + } + } + + 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/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralManager.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralManager.swift index bf901a7..d0b728a 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralManager.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Peripheral/PeripheralManager.swift @@ -27,28 +27,34 @@ import CoreBluetooth import SwiftUI -class PeripheralManager: NSObject, ObservableObject, CBPeripheralManagerDelegate { - @Published var state: CBManagerState = .unknown - @Published var isAdvertising: Bool = false - private var peripheralManager: CBPeripheralManager! +@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? - 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 @@ class PeripheralManager: NSObject, ObservableObject, CBPeripheralManagerDelegate 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 @@ class PeripheralManager: NSObject, ObservableObject, CBPeripheralManagerDelegate 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 fa174ec..38e9fe6 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 @@ -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() } diff --git a/Package.resolved b/Package.resolved index cc188bf..fa12bf0 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,22 @@ { - "originHash" : "50ae41968c3c2a4917878110142f17be1230d8ea8407d801971ea65b33603c99", + "originHash" : "379c094ef4c3a923a4a2c21c4e25ed1058c6b3865059734470458f26ec9827d3", "pins" : [ + { + "identity" : "ios-corebluetooth-mock", + "kind" : "remoteSourceControl", + "location" : "https://github.com/NordicSemiconductor/IOS-CoreBluetooth-Mock.git", + "state" : { + "revision" : "5748c9e8b1750e0d7bc09243c099ff618f211cdf", + "version" : "1.0.6" + } + }, { "identity" : "swift-docc-plugin", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-docc-plugin", "state" : { - "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", - "version" : "1.4.3" + "revision" : "647c708be89f834fa6a6d4945442793a77ddf5b6", + "version" : "1.5.0" } }, { @@ -25,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 3fc11f1..f07921e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,11 +1,11 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.1 import PackageDescription let package = Package( name: "ReliaBLE", platforms: [ - .iOS(.v16), + .iOS(.v18), .macOS(.v10_15) ], products: [ @@ -16,12 +16,13 @@ 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( 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/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/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift new file mode 100644 index 0000000..7132303 --- /dev/null +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -0,0 +1,656 @@ +// +// 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 CoreBluetooth +import Foundation + +// MARK: - Sendable Bridging Helper + +/// 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`. +/// 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. +private struct DiscoveryPayload: @unchecked Sendable { + let peripheral: CBPeripheral + let advertisementData: [String: Any] + 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. +/// +/// 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. +/// +/// 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 +actor BluetoothActor { + /// The process-lifetime shared instance. + static let shared = BluetoothActor() + + // MARK: - Actor-Isolated State + + private let centralManagerQueue = DispatchQueue(label: "com.five3apps.relia-ble.bluetoothmanager", qos: .userInitiated) + + 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 + + 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] = [:] + + // MARK: - AsyncStream Broadcaster State + // + // 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. + + private var stateContinuations: [UUID: AsyncStream.Continuation] = [:] + private var discoveryContinuations: [UUID: AsyncStream.Continuation] = [:] + private var peripheralsContinuations: [UUID: AsyncStream<[Peripheral]>.Continuation] = [:] + + // MARK: - Initialization + + 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) } + } + } + + /// 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(bufferingPolicy: .bufferingNewest(BluetoothActor.discoveryBufferLimit)) { 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 + + func configure(log: LoggingService) { + self.log = log + } + + /// 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() + } + } + + // MARK: - Central Manager Setup + + func setupCentralManager() { + guard centralManager == nil else { return } + + log?.info("Initializing CBCentralManager") + + // 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 + + /// 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: + throw AuthorizationError.restricted + case .allowedAlways: + setupCentralManager() + @unknown default: + throw AuthorizationError.unknown + } + } + + /// 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) { + 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) { + // 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) + } + + // 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() + resolvePendingAuthorization() + } + + func handlePeripheralDiscovered( + _ cbPeripheral: CBPeripheral, + advertisementData: [String: Any], + rssi: Int + ) { + // 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 + broadcast( + PeripheralDiscoveryEvent(cbPeripheral: cbPeripheral, advertisement: advertisement, rssi: rssi), + to: discoveryContinuations + ) + + // 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 + + 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) + } + + // Stash the live reference under the resolved id. Never escapes the actor. + cbPeripherals[resolvedId] = cbPeripheral + broadcast(discoveredPeripherals, to: peripheralsContinuations) + } + + private func invalidatePeripherals() { + // The value snapshots hold no CoreBluetooth reference to clear; drop the live registry instead. + cbPeripherals.removeAll() + broadcast(discoveredPeripherals, to: peripheralsContinuations) + log?.debug("Invalidated all peripheral references") + } + + private func refreshPeripherals() { + guard let centralManager else { return } + + let identifiers = discoveredPeripherals.compactMap { $0.cbIdentifier } + 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.cbIdentifier == cbPeripheral.identifier }) { + cbPeripherals[p.id] = cbPeripheral + } + } + broadcast(discoveredPeripherals, to: peripheralsContinuations) + 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 centralManager else { + // 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") + throw PeripheralError.bluetoothUnavailable + } + + guard let cbPeripheral = cbPeripherals[id] else { + throw PeripheralError.notFound + } + + centralManager.connect(cbPeripheral, options: nil) + } +} + +// 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 { + + /// 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) { + // Yielding is synchronous and thread-safe; ordering is preserved because CoreBluetooth + // invokes delegate methods serially on its dispatch queue. + eventContinuation.yield(.stateUpdate) + } + + func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + // 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) + eventContinuation.yield(.discovered(payload)) + } +} diff --git a/Sources/ReliaBLE/BluetoothManager.swift b/Sources/ReliaBLE/BluetoothManager.swift deleted file mode 100644 index 426b7c4..0000000 --- a/Sources/ReliaBLE/BluetoothManager.swift +++ /dev/null @@ -1,329 +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 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 { - private let log: LoggingService - private let peripheralManager: PeripheralManager - - private var centralManager: CBCentralManager? - private let queue = DispatchQueue(label: "com.five3apps.relia-ble.bluetoothmanager", qos: .userInitiated, attributes: [.concurrent]) - - // 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, peripheralManager: PeripheralManager) { - 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) - } - - // MARK: - State - - private let stateSubject = CurrentValueSubject(.unknown) - - /// Publisher for the real-time state of the underlying Core Bluetooth system. - var state: AnyPublisher { - stateSubject.eraseToAnyPublisher() - } - - /// Synchronous access to the current state of the underlying Core Bluetooth system. - var currentState: BluetoothState { - stateSubject.value - } - - 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. - /// - /// - 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 - } - } - - // 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") - } - } - - // MARK: - Peripheral Discovery - - private let discoverySubject = PassthroughSubject() - - /// Publisher that emits peripheral discovery events during scanning. - public var peripheralDiscoveries: AnyPublisher { - discoverySubject.eraseToAnyPublisher() - } - - // 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() - } - - 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) - } -} - -// 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/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 ca560ed..68f5de0 100644 --- a/Sources/ReliaBLE/Documentation.docc/Documentation.md +++ b/Sources/ReliaBLE/Documentation.docc/Documentation.md @@ -13,6 +13,18 @@ This is a temporary overview. - - +### Peripherals + +- ``Peripheral`` +- ``AdvertisementData`` +- ``PeripheralDiscoveryEvent`` +- ``PeripheralError`` + +### Concurrency & Isolation + +- +- ``ReliaBLEManager`` + ### Advanced Usage - diff --git a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md index 8eb332c..8cc8079 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 { @@ -52,25 +52,26 @@ 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: 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`. -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. +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 @@ -86,13 +87,12 @@ Example of starting and stopping a scan for all peripherals: ```swift // Check if Bluetooth is ready -if bleManager.currentState == .ready { - bleManager.startScanning() +if await bleManager.currentState == .ready { + 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") @@ -105,18 +105,56 @@ 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 - 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") } ``` -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 + +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. 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. + +```swift +for await peripherals in bleManager.discoveredPeripherals { + for peripheral in peripherals { + print(peripheral.name ?? peripheral.id, peripheral.advertisement?.serviceUUIDs ?? []) + } +} +``` + +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. +} catch PeripheralError.bluetoothUnavailable { + // Bluetooth has not been set up yet (for example, not authorized). Authorize and wait + // for the `.ready` state before retrying. +} +``` + +- 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/Documentation.docc/Topics/Concurrency.md b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md new file mode 100644 index 0000000..1745eef --- /dev/null +++ b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md @@ -0,0 +1,85 @@ +# 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. 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. + +### 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/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..dede711 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,12 @@ 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 { +/// +/// 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/Logging/LoggingService.swift b/Sources/ReliaBLE/Logging/LoggingService.swift index d91a365..3955960 100644 --- a/Sources/ReliaBLE/Logging/LoggingService.swift +++ b/Sources/ReliaBLE/Logging/LoggingService.swift @@ -24,10 +24,10 @@ import Foundation -@preconcurrency import Willow +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/AdvertisementData.swift b/Sources/ReliaBLE/Models/AdvertisementData.swift new file mode 100644 index 0000000..d59d205 --- /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 +/// 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. +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 the library, 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 ede255d..2652307 100644 --- a/Sources/ReliaBLE/Models/Peripheral.swift +++ b/Sources/ReliaBLE/Models/Peripheral.swift @@ -24,98 +24,93 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -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 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. /// -/// 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 { - /// 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 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. +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. - var peripheralIdentifier: UUID? - - /// 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? - - /// The name advertised by the peripheral, if available - public var name: String? { - peripheral?.name ?? advertisementData?[CBAdvertisementDataLocalNameKey] as? String - } - - /// Advertised service UUIDs - public var serviceUUIDs: [CBUUID]? { - advertisementData?[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] + /// `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) } - - /// Signal strength indicator (RSSI) of the most recent advertisement - public internal(set) var rssi: Int? - - /// Complete advertisement data dictionary from the most recent discovery - public internal(set) var advertisementData: [String: Any]? - - /// The timestamp when the peripheral was last seen - public internal(set) var lastSeen: Date? - - /// Creates a peripheral with a unique identifier and optional CoreBluetooth discovery data. + + /// Creates a fully-specified peripheral snapshot. Used internally at discovery time. /// - /// Prefer observing ``ReliaBLEManager/discoveredPeripherals`` for devices discovered during scanning. - /// ReliaBLE updates only the ``Peripheral`` instances it manages internally and emits through that publisher. /// - 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 - self.peripheralIdentifier = peripheral?.identifier - self.peripheral = peripheral + self.cbIdentifier = cbIdentifier + self.name = name self.rssi = rssi - self.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() - } + self.lastSeen = lastSeen + self.advertisement = advertisement } - - func update(cbPeripheral: CBPeripheral, advertisementData: [String: Any]? = nil, rssi: Int? = nil) { - self.peripheralIdentifier = cbPeripheral.identifier - self.peripheral = cbPeripheral - self.advertisementData = advertisementData - self.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() - } - } - + 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 + // Equality keys on `id` only: the identifier is unique and the matching between `Peripheral` snapshots and + // 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 new file mode 100644 index 0000000..dceff7b --- /dev/null +++ b/Sources/ReliaBLE/Models/PeripheralError.swift @@ -0,0 +1,44 @@ +// +// 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. + + +/// Errors thrown by peripheral operations such as ``ReliaBLEManager/connect(to:)``. +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 + /// 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 + + /// 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/PeripheralManager.swift b/Sources/ReliaBLE/PeripheralManager.swift deleted file mode 100644 index b440967..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 - -class PeripheralManager { - 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, attributes: [.concurrent]) - - 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 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.peripheral = nil - } - 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/ReliaBLEConfig.swift b/Sources/ReliaBLE/ReliaBLEConfig.swift index 20fd13b..23161a2 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. @@ -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/Sources/ReliaBLE/ReliaBLEManager.swift b/Sources/ReliaBLE/ReliaBLEManager.swift index 68deffc..4095405 100644 --- a/Sources/ReliaBLE/ReliaBLEManager.swift +++ b/Sources/ReliaBLE/ReliaBLEManager.swift @@ -24,19 +24,21 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import Combine -import Foundation import CoreBluetooth +import Foundation -@preconcurrency import Willow +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 - private let peripheralManager: PeripheralManager /// Initializes the ReliaBLEManager with the provided configuration, or a default configuration if none is provided. /// @@ -51,41 +53,78 @@ public class ReliaBLEManager { loggingService.enabled = config.loggingEnabled log = loggingService - peripheralManager = PeripheralManager(loggingService: loggingService) - bluetoothManager = BluetoothManager(loggingService: loggingService, peripheralManager: peripheralManager) + + // `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 - /// Publisher for the real-time state of the underlying Core Bluetooth system. - public var state: AnyPublisher { - bluetoothManager.state + /// 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 { + 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 - /// 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. - public func authorizeBluetooth() throws { - try bluetoothManager.authorize() + /// 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 { + 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 - /// 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 { - bluetoothManager.peripheralDiscoveries + /// 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 { + BluetoothActor.shared.peripheralDiscoveriesStream() } - - /// Publisher that emits the current list of discovered peripherals. - public var discoveredPeripherals: AnyPublisher<[Peripheral], Never> { - peripheralManager.discoveredPeripheralsPublisher + + /// 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]> { + BluetoothActor.shared.discoveredPeripheralsStream() } /// Starts scanning for peripheral devices, optionally filtering by specific services. @@ -95,16 +134,114 @@ 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 BluetoothActor.shared.ensureInitialized(log: log) + await BluetoothActor.shared.startScanning(services: services) } /// Stops scanning for peripheral devices. - public func stopScanning() { - bluetoothManager.stopScanning() + public func stopScanning() async { + await BluetoothActor.shared.ensureInitialized(log: log) + await BluetoothActor.shared.stopScanning() } - func testFunction() -> String { - return "Hello, this is ReliaBLE!" + // 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), 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) } } + +// 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 + + /// 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, Sendable { + /// 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 f420a97..cbec972 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -27,8 +27,155 @@ 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 + 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 +} + +@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 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) + Issue.record("Expected connect(to:) to throw for an unknown peripheral") + } catch let error as PeripheralError { + #expect(error == .notFound || error == .bluetoothUnavailable) + } +} + +// 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) +} + +@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, + 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 + } } 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. 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) | 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..8fe73de --- /dev/null +++ b/docs/plans/bluetooth-actor-migration-2026-06-08.md @@ -0,0 +1,101 @@ +# 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 + +> **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 + +## 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 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/plans/demo-background-swiftdata-2026-06-27.md b/docs/plans/demo-background-swiftdata-2026-06-27.md new file mode 100644 index 0000000..49bc2ee --- /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`, 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. + +**`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 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/plans/peripheral-sendable-struct-2026-06-13.md b/docs/plans/peripheral-sendable-struct-2026-06-13.md new file mode 100644 index 0000000..e9fa8d7 --- /dev/null +++ b/docs/plans/peripheral-sendable-struct-2026-06-13.md @@ -0,0 +1,181 @@ +# 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. + +**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. + +### 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. + +### 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. 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/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. 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 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 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 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