Why this happens
On iOS 13 and later, an app cannot touch Bluetooth until the user grants the Bluetooth permission. iOS decides whether to show that prompt based on one thing: a top-level NSBluetoothAlwaysUsageDescription key in your app’s Info.plist. If the key is missing, CoreBluetooth still initializes, but the system treats the app as if Bluetooth hardware is unavailable — CBCentralManager.state reports .unsupported (raw value 2) and centralManagerDidUpdateState fires once with no prompt ever shown.
The trap in this case is that the usage string is in the plist — just in the wrong place. It was placed inside UTImportedTypeDeclarations, as the UTTypeDescription of an imported type whose UTTypeIdentifier happens to be the string NSBluetoothAlwaysUsageDescription. That is a completely unrelated plist section: UTImportedTypeDeclarations describes document types your app can open, and iOS never reads permission strings from it. Xcode generated the skeleton, but the key ended up nested where it has no effect.
A secondary issue in the sample code: BluetoothManager is created as a private var inside the ContentView struct. SwiftUI re-creates the struct constantly, so a fresh CBCentralManager can be initialized repeatedly. That won’t block the first permission prompt, but it makes state handling unreliable once you do fix the plist — use @StateObject instead.
How to fix it
1. Add a top-level NSBluetoothAlwaysUsageDescription key
Open your target’s Info tab (or the Info.plist file) and add a top-level dictionary entry:
- Key:
Privacy - Bluetooth Always Usage Description(raw keyNSBluetoothAlwaysUsageDescription) - Type: String
- Value: a sentence explaining why you need Bluetooth, e.g. “This app scans for nearby smart accessories so it can connect to them.”
If you edit the XML directly, the key must sit directly inside the root <dict>, not inside any nested structure:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app scans for nearby smart accessories so it can connect to them.</string>
</dict>
</plist>
You can safely delete the bogus UTImportedTypeDeclarations entry — it does nothing for Bluetooth and, as written, declares an imported type with an empty UTTypeConformsTo array, which is meaningless anyway.
Then clean and rebuild so the new plist is actually embedded in the app bundle (a plain Build sometimes reuses the old signed bundle):
Product → Clean Build Folder (⇧⌘K)
Delete the app from the phone and reinstall. iOS caches permission decisions per app install, and a stale install with no Bluetooth usage key can keep reporting .unsupported even after you fix the plist.
2. Hold the manager in @StateObject and react to state correctly
With the plist fixed, the prompt appears the first time a CBCentralManager (or CBPeripheralManager) is initialized. Make sure you create it exactly once and keep it alive:
import CoreBluetooth
import SwiftUI
final class BluetoothManager: NSObject, ObservableObject, CBCentralManagerDelegate {
@Published var discoveredDevices: [CBPeripheral] = []
@Published var bluetoothReady = false
private var centralManager: CBCentralManager!
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: .main)
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
switch central.state {
case .poweredOn:
bluetoothReady = true
centralManager.scanForPeripherals(withServices: nil)
case .unsupported:
print("Bluetooth unsupported — check NSBluetoothAlwaysUsageDescription")
default:
print("Bluetooth not ready: \(central.state.rawValue)")
}
}
func centralManager(_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber) {
if !discoveredDevices.contains(peripheral) {
discoveredDevices.append(peripheral)
}
}
}
struct ContentView: View {
@StateObject private var bluetoothManager = BluetoothManager()
var body: some View {
List {
ForEach(bluetoothManager.discoveredDevices, id: \.identifier) { peripheral in
Text(peripheral.name ?? "No name")
}
}
}
}
Two details matter here:
@StateObjectkeeps oneBluetoothManagerinstance for the view’s lifetime, so the permission prompt fires once and the delegate survives SwiftUI re-renders.discoveredDevicesneeds@Published(and the class needsObservableObjectconformance, which it already had) for theListto refresh — the original code stored plainvars that SwiftUI never observes.
3. If the prompt still doesn’t appear
- Check the built bundle, not the source. In the build products folder, open the app’s Info.plist and confirm
NSBluetoothAlwaysUsageDescriptionis at the top level. If you use generated plists (GENERATE_INFOPLIST_FILE = YES), add the key under Target → Info → Custom iOS Target Properties or in Build Settings’ Info.plist values instead of a file Xcode ignores. - Reset permissions: Settings → General → Transfer or Reset iPhone → Reset → Reset Location & Privacy, then reinstall. Or simply delete the app and install again.
- Verify on a real device. The simulator’s Bluetooth support is limited; state values can differ from hardware. Test the prompt on the physical phone.
- Check for a deny rule: if the app was previously denied (Settings → your app → Bluetooth → Off), no prompt appears and state will be
.poweredOff-adjacent behavior; re-enable it there for testing.
How to prevent it
- Always add privacy usage keys through the Target → Info tab so Xcode puts them at the top level of the plist, never inside nested arrays.
- Never hand-edit
UTImportedTypeDeclarationsfor permissions — that section is only for document types. - Keep CoreBluetooth managers in an
@StateObject/owned long-lived object, not a struct property. - After changing Info.plist on a device, delete the app and reinstall so cached permission state is cleared.
- Test the first-launch flow on a real device, where the permission alert actually shows.
FAQ
Why does CBCentralManager report state 2 instead of crashing when the key is missing?
CoreBluetooth degrades gracefully: without the usage description the system treats the app as lacking Bluetooth capability, so the central manager reports .unsupported (raw value 2) rather than failing to initialize. It’s Apple’s way of signaling “this app may not use Bluetooth” without an exception.
Do I need NSBluetoothPeripheralUsageDescription too?
Only if you support iOS 12 or earlier. NSBluetoothPeripheralUsageDescription is the legacy pre-iOS-13 key; on iOS 13+ the always key (NSBluetoothAlwaysUsageDescription) covers both central and peripheral roles.
The prompt appeared once and never again — is that a bug?
No. iOS shows the Bluetooth permission alert only the first time the app initializes a CoreBluetooth object. After that, the decision lives in Settings under your app’s entry; flip Bluetooth off/on there to re-test, or delete and reinstall the app to see the prompt again.
Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.
