Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,19 @@ The package declares **three targets** that share a single source tree to make `

### 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 `BluetoothActor.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.
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 serialize all Bluetooth interactions on `BluetoothActor` (a plain `actor` defined in `BluetoothActor.swift`). This keeps the library thread-safe and allows the integrating app to decide how to bridge to the main thread for UI updates.

### Logging
`BluetoothActor` is **not** a `@globalActor` and has no shared singleton. Each `ReliaBLEManager` **owns its own** `BluetoothActor` instance, created synchronously in the manager's `init` (`BluetoothActor(log:reconnectPolicy:restoreIdentifier:)`). There is no `@BluetoothActor` annotation, no `.shared`, and no process-wide state — do not reintroduce any of these.

`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.
**One stack per manager.** Each `ReliaBLEManager` is a fully isolated stack: its own actor, `CBCentralManager`, discovered-peripheral snapshots, connection state, and streams. Constructing a second manager yields a second, independent stack — config (logging, `reconnectPolicy`) applies per manager, not first-wins.

### Authorization flow
### Logging

`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 `BluetoothActor.setupCentralManager()`.
`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.

## Notes for editing

- **This library is in pre-release development stage.** Breaking changes are expected. Do not reference behavior history in any library documentation or code comments (noting in planning docs is acceptable and expected). Do not waste time thinking about mitigating breaking changes. Focus on the current design and implementation.
- 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 `BluetoothActor`. 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.
40 changes: 31 additions & 9 deletions Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ extension ConnectionState {

struct CentralView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.bleManager) private var reliaBLE
/// Optional because SwiftUI evaluates `EnvironmentKey.defaultValue` while wiring keys;
/// App injects the real manager on the root content view.
@Environment(\.bleManager) private var bleManager

@Query private var discoveries: [DiscoveryEvent]
@Query private var devices: [Device]
Expand All @@ -88,6 +90,22 @@ struct CentralView: View {
@State private var selectedView: String = "Devices"

var body: some View {
Group {
if let reliaBLE = bleManager {
centralContent(reliaBLE: reliaBLE)
} else {
// Defensive only: App/previews always inject. Not a product UX state to design around.
ContentUnavailableView(
"Bluetooth Manager Missing",
systemImage: "antenna.radiowaves.left.and.right.slash",
description: Text("Inject ReliaBLEManager via .environment(\\.bleManager, …).")
)
}
}
}

@ViewBuilder
private func centralContent(reliaBLE: ReliaBLEManager) -> some View {
NavigationSplitView {
Text("ReliaBLE state: \(viewModel.currentState.description)")

Expand Down Expand Up @@ -131,7 +149,7 @@ struct CentralView: View {

Group {
if selectedView == "Devices" {
deviceList
deviceList(reliaBLE: reliaBLE)
} else {
discoveriesList
}
Expand All @@ -152,36 +170,35 @@ struct CentralView: View {
Text("Select a device")
}
.task {
let manager = reliaBLE
let store = await DeviceStoreActor.create(container: modelContext.container)
viewModel.setDependencies(deviceStore: store, reliaBLE: manager)
viewModel.setDependencies(deviceStore: store, reliaBLE: reliaBLE)

await withTaskGroup(of: Void.self) { group in
group.addTask {
for await state in manager.state {
for await state in reliaBLE.state {
await viewModel.updateState(state)
}
}
group.addTask {
for await discoveryEvent in manager.peripheralDiscoveries {
for await discoveryEvent in reliaBLE.peripheralDiscoveries {
await store.insertDiscovery(discoveryEvent)
}
}
group.addTask {
for await peripherals in manager.discoveredPeripherals {
for await peripherals in reliaBLE.discoveredPeripherals {
await store.syncDevices(peripherals)
}
}
group.addTask {
for await change in manager.connectionStateChanges {
for await change in reliaBLE.connectionStateChanges {
await viewModel.updateConnectionState(change)
}
}
}
}
}

private var deviceList: some View {
private func deviceList(reliaBLE: ReliaBLEManager) -> some View {
List {
ForEach(devices, id: \.persistentModelID) { device in
NavigationLink {
Expand Down Expand Up @@ -313,4 +330,9 @@ private struct CountdownView: View {
for: [Device.self, DiscoveryEvent.self],
inMemory: true
)
.environment(\.bleManager, {
var config = ReliaBLEConfig()
config.restoreIdentifier = "com.five3apps.relia-ble-demo.preview"
return ReliaBLEManager(config: config)
}())
}
6 changes: 6 additions & 0 deletions Demo/ReliaBLE Demo/ReliaBLE Demo/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
// SOFTWARE.

import SwiftUI
import ReliaBLE

struct ContentView: View {
var body: some View {
Expand All @@ -47,4 +48,9 @@ struct ContentView: View {

#Preview {
ContentView()
.environment(\.bleManager, {
var config = ReliaBLEConfig()
config.restoreIdentifier = "com.five3apps.relia-ble-demo.preview"
return ReliaBLEManager(config: config)
}())
}
17 changes: 14 additions & 3 deletions Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,20 @@ import SwiftData
import ReliaBLE

private struct BLEManagerKey: EnvironmentKey {
static let defaultValue: ReliaBLEManager = ReliaBLEManager(config: ReliaBLEConfig())
/// Must stay cheap and side-effect free.
///
/// SwiftUI evaluates `defaultValue` while wiring environments (including when *setting*
/// `.environment(\.bleManager, …)` via a writable key path) — not only when a view reads a
/// missing value. So this must never `fatalError` and must never construct a
/// ``ReliaBLEManager`` (that would spin up a second `CBCentralManager` stack, often without
/// the app's `restoreIdentifier`).
///
/// The real manager is injected once from ``ReliaBLE_DemoApp``.
static let defaultValue: ReliaBLEManager? = nil
}

extension EnvironmentValues {
var bleManager: ReliaBLEManager {
var bleManager: ReliaBLEManager? {
get { self[BLEManagerKey.self] }
set { self[BLEManagerKey.self] = newValue }
}
Expand Down Expand Up @@ -77,8 +86,10 @@ struct ReliaBLE_DemoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
// Inject on the root content (not only the Scene) so descendants always see the
// real manager after the scene graph materializes.
.environment(\.bleManager, reliaBLE)
}
.modelContainer(sharedModelContainer)
.environment(\.bleManager, reliaBLE)
}
}
17 changes: 14 additions & 3 deletions Demo/ReliaBLE Demo/ReliaBLE Demo/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ import SwiftUI
import ReliaBLE

struct SettingsView: View {
@Environment(\.bleManager) private var reliaBLE
/// Optional because SwiftUI evaluates `EnvironmentKey.defaultValue` while wiring keys;
/// App injects the real manager on the root content view.
@Environment(\.bleManager) private var bleManager
@State private var isLoggingEnabled: Bool = false

@AppStorage("reconnectPolicy.maxAttempts") private var maxAttempts = 5
Expand All @@ -41,7 +43,9 @@ struct SettingsView: View {
NavigationView {
Form {
Section("Logging") {
// Defensive only: App/previews always inject; disable is not a product UX path.
Toggle("Enable Logging", isOn: $isLoggingEnabled)
.disabled(bleManager == nil)
}

Section {
Expand Down Expand Up @@ -72,14 +76,21 @@ struct SettingsView: View {
.navigationTitle("Settings")
}
.onAppear {
isLoggingEnabled = reliaBLE.loggingService.enabled
if let bleManager {
isLoggingEnabled = bleManager.loggingService.enabled
}
}
.onChange(of: isLoggingEnabled) { _, newValue in
reliaBLE.loggingService.enabled = newValue
bleManager?.loggingService.enabled = newValue
}
}
}

#Preview {
SettingsView()
.environment(\.bleManager, {
var config = ReliaBLEConfig()
config.restoreIdentifier = "com.five3apps.relia-ble-demo.preview"
return ReliaBLEManager(config: config)
}())
}
6 changes: 3 additions & 3 deletions PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,9 @@ Detail and rationale: `docs/designs/discovered-peripheral-vs-peripheral-2026-07-
- FR-8.2.1: Allow the library to scan continuously for BLE peripherals, providing real-time updates about nearby devices as **`DiscoveredPeripheral`** values (and/or equivalent), each resolvable to a `Peripheral` handle.
- FR-8.2.2: Provide an interface for the app to start, stop, and check the status of the continuous scanning process.

- FR-8.3: Background Scanning:
- FR-8.3.1: Implement background scanning capabilities, ensuring compliance with iOS background execution rules.
- FR-8.3.2: Notify the integrating app when new devices come into range even when the app is not in the foreground, using appropriate iOS background modes like bluetooth-central.
- FR-8.3: Background Scanning:
- FR-8.3.1: Implement background scanning capabilities, ensuring compliance with iOS background execution rules.
- FR-8.3.2: Notify the integrating app when new devices come into range even when the app is not in the foreground, using appropriate iOS background modes like bluetooth-central.

- ✅ FR-8.4: Processing of Advertisement Data:
- ✅ FR-8.4.1: Extract and make available manufacturing data from advertisement packets to the integrating app.
Expand Down
Loading
Loading