@@ -18,11 +18,13 @@ public actor CoreBluetoothPeripheralManager: BLEPeripheralManagerProtocol {
1818 private var services : [ UUID : CBMutableService ] = [ : ]
1919 private var characteristics : [ UUID : CBMutableCharacteristic ] = [ : ]
2020 private var subscribedCentrals : [ UUID : Set < CBCentral > ] = [ : ]
21+ private var subscribedCentralIDs : [ UUID : Set < UUID > ] = [ : ] // Track central UUIDs for API compatibility
2122 private var rpcCharacteristics : Set < UUID > = [ ] // Track RPC characteristics
2223
2324 // Continuations for async operations
2425 private var stateContinuations : [ CheckedContinuation < CBManagerState , Never > ] = [ ]
2526 private var advertisingContinuation : CheckedContinuation < Void , Error > ?
27+ private var serviceAddContinuation : CheckedContinuation < Void , Error > ?
2628
2729 // MARK: - Initialization
2830
@@ -104,8 +106,11 @@ public actor CoreBluetoothPeripheralManager: BLEPeripheralManagerProtocol {
104106 cbService. characteristics = cbCharacteristics
105107 services [ service. uuid] = cbService
106108
107- // Add service to peripheral manager
108- peripheralManager? . add ( cbService)
109+ // Add service to peripheral manager and wait for completion
110+ try await withCheckedThrowingContinuation { ( continuation: CheckedContinuation < Void , Error > ) in
111+ serviceAddContinuation = continuation
112+ peripheralManager? . add ( cbService)
113+ }
109114 }
110115
111116 public func startAdvertising( _ data: AdvertisementData ) async throws {
@@ -165,30 +170,59 @@ public actor CoreBluetoothPeripheralManager: BLEPeripheralManagerProtocol {
165170 throw BleuError . characteristicNotFound ( characteristicUUID)
166171 }
167172
173+ guard let peripheralManager = peripheralManager else {
174+ throw BleuError . bluetoothUnavailable
175+ }
176+
168177 // Convert UUID array to CBCentral array if provided
169- // Note: We can only send to centrals we know about through subscriptions
170- let centralsToUpdate : [ CBCentral ] ?
171- if centrals != nil {
172- // Filter subscribedCentrals to only those in the requested list
178+ // Filter by central.identifier to respect the centrals parameter
179+ let centralsToUpdate : [ CBCentral ]
180+ if let requestedCentralIDs = centrals {
173181 let allSubscribed = subscribedCentrals [ characteristicUUID] ?? [ ]
174- centralsToUpdate = Array ( allSubscribed)
175- // Note: We cannot filter by UUID as CBCentral doesn't expose its identifier in peripheral role
182+ // Filter to only centrals whose identifier is in the requested list
183+ let filtered = Array ( allSubscribed. filter { requestedCentralIDs. contains ( $0. identifier) } )
184+
185+ // CRITICAL SECURITY FIX: If filter result is empty, DO NOT send to anyone
186+ // Sending nil to CoreBluetooth broadcasts to ALL subscribers (privacy leak!)
187+ if filtered. isEmpty {
188+ BleuLogger . peripheral. error ( " No matching centrals found for UUIDs \( requestedCentralIDs) - refusing to broadcast " )
189+ throw BleuError . peripheralNotFound ( requestedCentralIDs. first ?? UUID ( ) )
190+ }
191+
192+ centralsToUpdate = filtered
176193 } else {
177- centralsToUpdate = subscribedCentrals [ characteristicUUID] . map { Array ( $0) }
194+ // No filter specified - send to all subscribers
195+ centralsToUpdate = Array ( subscribedCentrals [ characteristicUUID] ?? [ ] )
196+ }
197+
198+ // CRITICAL: Retry on failure to prevent dropped RPC responses
199+ // When updateValue returns false, the queue is full and the notification was NOT sent
200+ let maxRetries = 3
201+ for attempt in 0 ..< maxRetries {
202+ let success = peripheralManager. updateValue (
203+ data,
204+ for: characteristic,
205+ onSubscribedCentrals: centralsToUpdate. isEmpty ? nil : centralsToUpdate
206+ )
207+
208+ if success {
209+ return true
210+ }
211+
212+ // Queue full - wait and retry
213+ if attempt < maxRetries - 1 {
214+ BleuLogger . peripheral. warning ( " updateValue failed (queue full), retrying ( \( attempt + 1 ) / \( maxRetries) )... " )
215+ try await Task . sleep ( nanoseconds: 10_000_000 ) // 10ms
216+ }
178217 }
179218
180- return peripheralManager? . updateValue (
181- data,
182- for: characteristic,
183- onSubscribedCentrals: centralsToUpdate? . isEmpty == false ? centralsToUpdate : nil
184- ) ?? false
219+ // All retries exhausted - this is a critical error for RPC
220+ BleuLogger . peripheral. error ( " updateValue failed after \( maxRetries) retries - notification dropped! " )
221+ throw BleuError . rpcFailed ( " Failed to send notification after \( maxRetries) retries " )
185222 }
186223
187224 public func subscribedCentrals( for characteristicUUID: UUID ) async -> [ UUID ] {
188- // Note: CoreBluetooth doesn't provide central UUIDs in peripheral role
189- // We can only track CBCentral instances, not their identifiers
190- // Return empty array as we cannot provide UUIDs
191- return [ ]
225+ return Array ( subscribedCentralIDs [ characteristicUUID] ?? [ ] )
192226 }
193227}
194228
@@ -202,13 +236,13 @@ extension CoreBluetoothPeripheralManager {
202236 }
203237
204238 /// Track a read request for logging/monitoring
205- func trackReadRequest( characteristicUUID: String , serviceUUID: String ? ) async {
239+ func trackReadRequest( centralID : UUID , characteristicUUID: String , serviceUUID: String ? ) async {
206240 guard let charUUID = UUID ( uuidString: characteristicUUID) else { return }
207241 let svcUUID = serviceUUID. flatMap ( UUID . init ( uuidString: ) ) ?? UUID ( )
208242
209243 // Send event for tracking
210244 await eventChannel. send ( . readRequestReceived(
211- UUID ( ) , // We don't have central ID here
245+ centralID , // Real central identifier from CBCentral
212246 svcUUID,
213247 charUUID
214248 ) )
@@ -229,8 +263,20 @@ extension CoreBluetoothPeripheralManager {
229263 }
230264
231265 func handleServiceAdded( _ service: CBService , error: Error ? ) async {
232- if let error = error {
233- BleuLogger . peripheral. error ( " Error adding service: \( error. localizedDescription) " )
266+ if let continuation = serviceAddContinuation {
267+ if let error = error {
268+ BleuLogger . peripheral. error ( " Error adding service: \( error. localizedDescription) " )
269+ continuation. resume ( throwing: error)
270+ } else {
271+ BleuLogger . peripheral. debug ( " Successfully added service: \( service. uuid) " )
272+ continuation. resume ( )
273+ }
274+ serviceAddContinuation = nil
275+ } else {
276+ // If no continuation is waiting, just log (shouldn't happen in normal flow)
277+ if let error = error {
278+ BleuLogger . peripheral. error ( " Error adding service (no continuation): \( error. localizedDescription) " )
279+ }
234280 }
235281 }
236282
@@ -246,10 +292,10 @@ extension CoreBluetoothPeripheralManager {
246292 }
247293
248294 func handleWriteRequests(
249- _ extractedRequests: [ ( serviceUUID: String ? , characteristicUUID: String , value: Data ? , offset: Int ) ]
295+ _ extractedRequests: [ ( centralID : UUID , serviceUUID: String ? , characteristicUUID: String , value: Data ? , offset: Int ) ]
250296 ) async {
251297 // Process the extracted requests
252- for (serviceUUID, characteristicUUID, value, _) in extractedRequests {
298+ for (centralID , serviceUUID, characteristicUUID, value, _) in extractedRequests {
253299 guard let charUUID = UUID ( uuidString: characteristicUUID) else { continue }
254300 let svcUUID = serviceUUID. flatMap ( UUID . init ( uuidString: ) ) ?? UUID ( )
255301
@@ -261,13 +307,13 @@ extension CoreBluetoothPeripheralManager {
261307 let transport = BLETransport . shared
262308 if let completeData = await transport. receive ( value) {
263309 // We have a complete message, process it
264- await handleRPCInvocation ( data: completeData, characteristicUUID: charUUID)
310+ await handleRPCInvocation ( data: completeData, characteristicUUID: charUUID, centralID : centralID )
265311 }
266312 // If nil, packet is part of a larger message, wait for more
267313 } else {
268314 // Regular characteristic write
269315 await eventChannel. send ( . writeRequestReceived(
270- UUID ( ) , // We don't have central ID here
316+ centralID , // Real central identifier from CBCentral
271317 svcUUID,
272318 charUUID,
273319 value
@@ -277,15 +323,15 @@ extension CoreBluetoothPeripheralManager {
277323 }
278324 }
279325
280- private func handleRPCInvocation( data: Data , characteristicUUID: UUID ) async {
326+ private func handleRPCInvocation( data: Data , characteristicUUID: UUID , centralID : UUID ) async {
281327 // Emit write event to EventBridge for RPC processing
282328 // EventBridge has the correct BLEActorSystem instance registered via setRPCRequestHandler()
283329 // This maintains proper separation of concerns and instance isolation
284330 await eventChannel. send ( . writeRequestReceived(
285- UUID ( ) , // central ID (CoreBluetooth limitation - unavailable in peripheral role)
286- UUID ( ) , // service UUID (would need to be tracked separately)
331+ centralID , // Real central identifier from CBCentral
332+ UUID ( ) , // service UUID (would need to be tracked separately)
287333 characteristicUUID,
288- data // Complete RPC data (already reassembled from fragments by BLETransport)
334+ data // Complete RPC data (already reassembled from fragments by BLETransport)
289335 ) )
290336 }
291337
@@ -300,22 +346,40 @@ extension CoreBluetoothPeripheralManager {
300346 }
301347
302348 let svcUUID = serviceUUID. flatMap ( UUID . init ( uuidString: ) ) ?? UUID ( )
349+ let centralID = central. identifier // Extract real central identifier
303350
304351 if subscribed {
305352 var centrals = subscribedCentrals [ charUUID] ?? [ ]
306353 centrals. insert ( central)
307354 subscribedCentrals [ charUUID] = centrals
308355
356+ var centralIDs = subscribedCentralIDs [ charUUID] ?? [ ]
357+ centralIDs. insert ( centralID)
358+ subscribedCentralIDs [ charUUID] = centralIDs
359+
360+ // CRITICAL: Update MTU for this central in BLETransport
361+ // This enables optimal packet fragmentation for responses to this central
362+ let maxUpdateValueLength = central. maximumUpdateValueLength
363+ let transport = BLETransport . shared
364+ await transport. updateMaxPayloadSize ( for: centralID, maxWriteLength: maxUpdateValueLength)
365+
366+ BleuLogger . peripheral. debug ( " Updated MTU for central \( centralID) : \( maxUpdateValueLength) bytes " )
367+
309368 await eventChannel. send ( . centralSubscribed(
310- UUID ( ) , // We don't have central ID - this is a limitation of CoreBluetooth
369+ centralID , // Real central identifier from CBCentral
311370 svcUUID,
312371 charUUID
313372 ) )
314373 } else {
315374 subscribedCentrals [ charUUID] ? . remove ( central)
375+ subscribedCentralIDs [ charUUID] ? . remove ( centralID)
376+
377+ // Clean up MTU entry when central disconnects
378+ let transport = BLETransport . shared
379+ await transport. removeMTU ( for: centralID)
316380
317381 await eventChannel. send ( . centralUnsubscribed(
318- UUID ( ) , // We don't have central ID - this is a limitation of CoreBluetooth
382+ centralID , // Real central identifier from CBCentral
319383 svcUUID,
320384 charUUID
321385 ) )
@@ -357,6 +421,7 @@ fileprivate final class CoreBluetoothPeripheralManagerDelegateProxy: NSObject, C
357421 public func peripheralManager( _ peripheral: CBPeripheralManager , didReceiveRead request: CBATTRequest ) {
358422 // Extract necessary data before Task
359423 let characteristicUUID = request. characteristic. uuid
424+ let centralID = request. central. identifier // Extract real central identifier
360425
361426 // Handle the response in the delegate to avoid passing non-Sendable objects
362427 Task { [ weak actor ] in
@@ -379,6 +444,7 @@ fileprivate final class CoreBluetoothPeripheralManagerDelegateProxy: NSObject, C
379444 let charUUIDString = characteristicUUID. uuidString
380445 let serviceUUIDString = request. characteristic. service? . uuid. uuidString
381446 await actor ? . trackReadRequest (
447+ centralID: centralID,
382448 characteristicUUID: charUUIDString,
383449 serviceUUID: serviceUUIDString
384450 )
@@ -387,9 +453,11 @@ fileprivate final class CoreBluetoothPeripheralManagerDelegateProxy: NSObject, C
387453
388454 public func peripheralManager( _ peripheral: CBPeripheralManager , didReceiveWrite requests: [ CBATTRequest ] ) {
389455 // Extract data from requests to avoid Sendable issues
390- let extractedRequests : [ ( serviceUUID: String ? , characteristicUUID: String , value: Data ? , offset: Int ) ] =
456+ // CRITICAL: Extract central.identifier for proper multi-client RPC support
457+ let extractedRequests : [ ( centralID: UUID , serviceUUID: String ? , characteristicUUID: String , value: Data ? , offset: Int ) ] =
391458 requests. map { request in
392459 (
460+ centralID: request. central. identifier,
393461 serviceUUID: request. characteristic. service? . uuid. uuidString,
394462 characteristicUUID: request. characteristic. uuid. uuidString,
395463 value: request. value,
0 commit comments