From bcd3e20669fb544cc8aade7e6d0ca2cccc6f3677 Mon Sep 17 00:00:00 2001 From: athexweb3 Date: Mon, 29 Jun 2026 11:28:19 +0600 Subject: [PATCH] feat(interpose): route peripheral GATT server Swizzle CBPeripheralManager to route a guest's GATT-server calls (publish, advertise, respond, notify) to the host, and deliver the host's peripheral events as the guest's delegate callbacks. Adds the seven peripheral client-transport functions and the peripheral shadow objects. --- packages/interpose/CMakeLists.txt | 9 +- packages/interpose/README.md | 13 +- packages/interpose/include/simble_interpose.h | 14 +- packages/interpose/src/hooks/central_hooks.m | 57 +-- packages/interpose/src/hooks/hooks_internal.h | 53 +++ .../interpose/src/hooks/peripheral_hooks.m | 408 ++++++++++++++++++ packages/interpose/src/registry/shadow.h | 127 ++++++ packages/interpose/src/registry/shadow.m | 244 ++++++++++- packages/interpose/src/transport/client.c | 84 ++++ packages/interpose/src/transport/client.h | 100 +++++ packages/interpose/tests/client_roundtrip.c | 50 ++- packages/interpose/tests/hook_smoke.c | 10 +- packages/interpose/tests/passthrough_test.m | 19 + .../interpose/tests/shadow_registry_test.m | 63 +++ 14 files changed, 1207 insertions(+), 44 deletions(-) create mode 100644 packages/interpose/src/hooks/hooks_internal.h create mode 100644 packages/interpose/src/hooks/peripheral_hooks.m diff --git a/packages/interpose/CMakeLists.txt b/packages/interpose/CMakeLists.txt index 8c302e2..629ef60 100644 --- a/packages/interpose/CMakeLists.txt +++ b/packages/interpose/CMakeLists.txt @@ -9,15 +9,16 @@ endif() set(SIMBLE_FRAMEWORKS "-framework CoreBluetooth" "-framework Foundation") -# The interposer core: the central swizzles, the shadow registry, and the loopback transport. -# No load constructor here; the dylib and the host harness add their own entry point, so both -# reuse this. The .m files use ARC. +# The interposer core: the central and peripheral swizzles, the shadow registry, and the loopback +# transport. No load constructor here; the dylib and the host harness add their own entry point, so +# both reuse this. The .m files use ARC. add_library(interpose_core STATIC src/hooks/central_hooks.m + src/hooks/peripheral_hooks.m src/registry/shadow.m src/transport/client.c ) -target_include_directories(interpose_core PUBLIC include src/transport src/registry) +target_include_directories(interpose_core PUBLIC include src/transport src/registry src/hooks) target_compile_options(interpose_core PRIVATE $<$:-fobjc-arc>) target_link_libraries(interpose_core PUBLIC simble_protocol_c ${SIMBLE_FRAMEWORKS}) diff --git a/packages/interpose/README.md b/packages/interpose/README.md index f43be77..d0f9354 100644 --- a/packages/interpose/README.md +++ b/packages/interpose/README.md @@ -7,9 +7,10 @@ SPDX-FileCopyrightText: 2026 Nirapod Labs The injected simulator dylib that routes a guest app's CoreBluetooth calls to the host. -It hooks the CoreBluetooth central surface with Objective-C runtime swizzling, holds a shadow -registry for the framework's opaque objects, and carries each operation to the helper over the -loopback transport, delivering host events back as the guest's delegate callbacks. Scan, connect, -service and characteristic discovery, read, write, notify, and RSSI are routed; the peripheral role -is not implemented. The dylib is a simulator slice, loaded only by a debug scheme and never by a -shipped app (see `SECURITY.md` for the fence). +It hooks the CoreBluetooth central and peripheral surfaces with Objective-C runtime swizzling, holds +a shadow registry for the framework's opaque objects, and carries each operation to the helper over +the loopback transport, delivering host events back as the guest's delegate callbacks. On the +central side: scan, connect, service and characteristic discovery, read, write, notify, and RSSI. On +the peripheral side: publish a service, advertise, respond to read and write requests, and notify +subscribers. The dylib is a simulator slice, loaded only by a debug scheme and never by a shipped +app (see `SECURITY.md` for the fence). diff --git a/packages/interpose/include/simble_interpose.h b/packages/interpose/include/simble_interpose.h index 33e6575..9d060d7 100644 --- a/packages/interpose/include/simble_interpose.h +++ b/packages/interpose/include/simble_interpose.h @@ -34,10 +34,10 @@ extern "C" { int simble_interpose_version(void); /** - * @brief Install the CoreBluetooth central swizzles. + * @brief Install the CoreBluetooth central and peripheral swizzles. * - * Exchanges the CBCentralManager and CBPeripheral method implementations for the routed - * ones. Idempotent: a second call is a no-op while the swizzles are installed. + * Exchanges the CBCentralManager, CBPeripheral, and CBPeripheralManager method implementations for + * the routed ones. Idempotent: a second call is a no-op while the swizzles are installed. * * @return The number of selectors that could not be swizzled; 0 means every one is in place. */ @@ -50,7 +50,7 @@ int simble_install_hooks(void); */ void simble_uninstall_hooks(void); -/** How many times each routed central operation has fired since install. */ +/** How many times each routed operation has fired since install. */ typedef struct { int scan_start; ///< scanForPeripheralsWithServices:options: calls routed. int connect; ///< connectPeripheral:options: calls routed to the helper. @@ -59,6 +59,12 @@ typedef struct { int read; ///< readValueForCharacteristic: calls routed to the helper. int write; ///< writeValue:forCharacteristic:type: calls routed. int set_notify; ///< setNotifyValue:forCharacteristic: calls routed. + int add_service; ///< addService: calls routed to the helper. + int remove_service; ///< removeService: and removeAllServices: calls routed. + int start_advertising; ///< startAdvertising: calls routed to the helper. + int respond; ///< respondToRequest:withResult: calls routed. + int update_value; ///< updateValue:forCharacteristic:onSubscribedCentrals: calls routed. + int peripheral_event; ///< peripheral events delivered to the guest delegate. } simble_hook_stats; /** diff --git a/packages/interpose/src/hooks/central_hooks.m b/packages/interpose/src/hooks/central_hooks.m index f886505..3493250 100644 --- a/packages/interpose/src/hooks/central_hooks.m +++ b/packages/interpose/src/hooks/central_hooks.m @@ -21,16 +21,19 @@ #import "../registry/shadow.h" #import "../transport/client.h" +#import "hooks_internal.h" #import "simble_interpose.h" #import #import #import -static simble_hook_stats g_stats = {0, 0, 0, 0, 0, 0, 0}; +static simble_hook_stats g_stats = {0}; static pthread_mutex_t g_install_lock = PTHREAD_MUTEX_INITIALIZER; static int g_installed = 0; +simble_hook_stats *simble_internal_stats(void) { return &g_stats; } + // The guest's bundle id and display name, for the HELLO the helper shows. Guest-reported, // names the app, gates nothing. static size_t copyAppId(char *buf, size_t cap) { @@ -154,8 +157,8 @@ static void deliverEvent(CBCentralManager *manager, const simble_event *event) { break; } default: - // A peripheral-role event is not delivered by the central interposer; the peripheral role - // is not implemented here. + // A peripheral-role event routes to the peripheral delivery path. + simble_deliver_peripheral_event(event); break; } } @@ -198,10 +201,10 @@ static void startEventReaderOnce(CBCentralManager *manager) { SEL replacement; } SwizzlePair; -static SwizzlePair g_pairs[16]; +static SwizzlePair g_pairs[24]; static size_t g_pair_count = 0; -static int swizzle(Class cls, SEL original, SEL replacement) { +int simble_swizzle(Class cls, SEL original, SEL replacement) { Method origMethod = class_getInstanceMethod(cls, original); Method replMethod = class_getInstanceMethod(cls, replacement); if (!origMethod || !replMethod) @@ -621,29 +624,31 @@ int simble_install_hooks(void) { } int failures = 0; Class managerClass = [CBCentralManager class]; - failures += swizzle(managerClass, @selector(initWithDelegate:queue:), - @selector(simble_initWithDelegate:queue:)); - failures += swizzle(managerClass, @selector(state), @selector(simble_state)); - failures += swizzle(managerClass, @selector(scanForPeripheralsWithServices:options:), - @selector(simble_scanForPeripheralsWithServices:options:)); - failures += swizzle(managerClass, @selector(stopScan), @selector(simble_stopScan)); - failures += swizzle(managerClass, @selector(connectPeripheral:options:), - @selector(simble_connectPeripheral:options:)); - failures += swizzle(managerClass, @selector(cancelPeripheralConnection:), - @selector(simble_cancelPeripheralConnection:)); + failures += simble_swizzle(managerClass, @selector(initWithDelegate:queue:), + @selector(simble_initWithDelegate:queue:)); + failures += simble_swizzle(managerClass, @selector(state), @selector(simble_state)); + failures += simble_swizzle(managerClass, @selector(scanForPeripheralsWithServices:options:), + @selector(simble_scanForPeripheralsWithServices:options:)); + failures += simble_swizzle(managerClass, @selector(stopScan), @selector(simble_stopScan)); + failures += simble_swizzle(managerClass, @selector(connectPeripheral:options:), + @selector(simble_connectPeripheral:options:)); + failures += simble_swizzle(managerClass, @selector(cancelPeripheralConnection:), + @selector(simble_cancelPeripheralConnection:)); Class peripheralClass = [CBPeripheral class]; - failures += - swizzle(peripheralClass, @selector(discoverServices:), @selector(simble_discoverServices:)); - failures += swizzle(peripheralClass, @selector(discoverCharacteristics:forService:), - @selector(simble_discoverCharacteristics:forService:)); - failures += swizzle(peripheralClass, @selector(readValueForCharacteristic:), - @selector(simble_readValueForCharacteristic:)); - failures += swizzle(peripheralClass, @selector(writeValue:forCharacteristic:type:), - @selector(simble_writeValue:forCharacteristic:type:)); - failures += swizzle(peripheralClass, @selector(setNotifyValue:forCharacteristic:), - @selector(simble_setNotifyValue:forCharacteristic:)); - failures += swizzle(peripheralClass, @selector(readRSSI), @selector(simble_readRSSI)); + failures += simble_swizzle(peripheralClass, @selector(discoverServices:), + @selector(simble_discoverServices:)); + failures += simble_swizzle(peripheralClass, @selector(discoverCharacteristics:forService:), + @selector(simble_discoverCharacteristics:forService:)); + failures += simble_swizzle(peripheralClass, @selector(readValueForCharacteristic:), + @selector(simble_readValueForCharacteristic:)); + failures += simble_swizzle(peripheralClass, @selector(writeValue:forCharacteristic:type:), + @selector(simble_writeValue:forCharacteristic:type:)); + failures += simble_swizzle(peripheralClass, @selector(setNotifyValue:forCharacteristic:), + @selector(simble_setNotifyValue:forCharacteristic:)); + failures += simble_swizzle(peripheralClass, @selector(readRSSI), @selector(simble_readRSSI)); + + failures += simble_install_peripheral_hooks(); g_installed = (failures == 0); pthread_mutex_unlock(&g_install_lock); diff --git a/packages/interpose/src/hooks/hooks_internal.h b/packages/interpose/src/hooks/hooks_internal.h new file mode 100644 index 0000000..eb9b7e7 --- /dev/null +++ b/packages/interpose/src/hooks/hooks_internal.h @@ -0,0 +1,53 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: 2026 Nirapod Labs + */ + +/** + * @file hooks_internal.h + * @brief Shared swizzle plumbing across the central and peripheral hook units. + * + * @details + * The central unit owns the route counters, the swizzle exchange, and the event reader; the + * peripheral unit reuses them through these declarations. Not a public header. + * + * @author Nirapod Labs + * @date 2026 + */ + +#ifndef SIMBLE_HOOKS_INTERNAL_H +#define SIMBLE_HOOKS_INTERNAL_H + +#import "simble_interpose.h" +#import "simble_protocol.h" + +#import + +/** + * @brief Exchange original and routed IMPs for a selector, recording the pair for uninstall. + * + * @param[in] cls The class carrying the selector. + * @param[in] original The selector to route. + * @param[in] replacement The routed selector. + * @return 0 on success, -1 when either method is absent. + */ +int simble_swizzle(Class cls, SEL original, SEL replacement); + +/** @brief The shared route counters the public simble_get_hook_stats reads. */ +simble_hook_stats *simble_internal_stats(void); + +/** + * @brief Install the CBPeripheralManager swizzles. + * + * @return The number of selectors that failed to swizzle. + */ +int simble_install_peripheral_hooks(void); + +/** + * @brief Translate one peripheral-role host event into the guest's delegate callbacks. + * + * @param[in] event The decoded peripheral event. + */ +void simble_deliver_peripheral_event(const simble_event *event); + +#endif diff --git a/packages/interpose/src/hooks/peripheral_hooks.m b/packages/interpose/src/hooks/peripheral_hooks.m new file mode 100644 index 0000000..5ca7a9d --- /dev/null +++ b/packages/interpose/src/hooks/peripheral_hooks.m @@ -0,0 +1,408 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: 2026 Nirapod Labs + */ + +/** + * @file peripheral_hooks.m + * @brief The peripheral swizzle set: CBPeripheralManager interception. + * + * @details + * The guest's GATT-server calls route to the host helper, and the host's peripheral events come + * back as the guest's own CBPeripheralManagerDelegate callbacks, dispatched on the queue the guest + * gave its manager. A call whose receiver the registry did not register passes through to the saved + * original implementation, so a non-managed CoreBluetooth user is byte-for-byte unaffected. No key + * material, pairing secret, or bonding record crosses the interposer: only GATT operations and byte + * payloads do. + * + * @author Nirapod Labs + * @date 2026 + */ + +#import "../registry/shadow.h" +#import "../transport/client.h" +#import "hooks_internal.h" +#import "simble_interpose.h" + +#import + +// Dispatch a block on the manager's queue, the main queue when it gave none. +static void dispatchOnPeripheralQueue(SimblePeripheralManagerEntry *entry, dispatch_block_t block) { + dispatch_queue_t queue = entry.queue ?: dispatch_get_main_queue(); + dispatch_async(queue, block); +} + +// --- The peripheral event stream: one reader thread, started on the first managed init --- + +// The reader loop: drain the event connection, deliver each peripheral event, exit on close. +static void runPeripheralEventReader(void) { + simble_conn conn; + if (simble_client_open(&conn) != SIMBLE_OK) + return; + for (;;) { + simble_event event; + if (simble_client_read_event(&conn, &event) != SIMBLE_OK) + break; + simble_deliver_peripheral_event(&event); + } + simble_client_close(&conn); +} + +// Start the peripheral event reader once per process, on the first managed manager. +static void startPeripheralEventReaderOnce(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + runPeripheralEventReader(); + }); + }); +} + +// --- Extraction helpers --- + +// Build parallel UUID, properties, and permissions arrays from a service's mutable characteristics. +// Returns the count; the NSArrays hold the UUID strings so the encoder reads stable pointers, and +// the caller frees the three malloc'd arrays. +static size_t buildCharacteristics(CBMutableService *service, NSMutableArray *held, + const char ***uuidsOut, size_t **lensOut, uint64_t **propsOut, + uint64_t **permsOut) { + size_t count = service.characteristics.count; + if (count == 0) { + *uuidsOut = NULL; + *lensOut = NULL; + *propsOut = NULL; + *permsOut = NULL; + return 0; + } + const char **uuids = calloc(count, sizeof(char *)); + size_t *lens = calloc(count, sizeof(size_t)); + uint64_t *props = calloc(count, sizeof(uint64_t)); + uint64_t *perms = calloc(count, sizeof(uint64_t)); + size_t i = 0; + for (CBCharacteristic *c in service.characteristics) { + NSString *s = c.UUID.UUIDString; + [held addObject:s]; + uuids[i] = s.UTF8String; + lens[i] = strlen(uuids[i]); + props[i] = (uint64_t)c.properties; + perms[i] = [c isKindOfClass:CBMutableCharacteristic.class] + ? (uint64_t)((CBMutableCharacteristic *)c).permissions + : 0; + i++; + } + *uuidsOut = uuids; + *lensOut = lens; + *propsOut = props; + *permsOut = perms; + return count; +} + +// --- CBPeripheralManager routed methods --- + +@interface CBPeripheralManager (SimblePeripheral) +@end + +@implementation CBPeripheralManager (SimblePeripheral) + +// Register the manager with its delegate and queue, then return the manager the original built. +- (instancetype)simble_initWithDelegate:(id)delegate + queue:(dispatch_queue_t)queue { + CBPeripheralManager *manager = [self simble_initWithDelegate:delegate queue:queue]; + if (manager) { + simble_shadow_register_peripheral_manager(manager, delegate, queue); + startPeripheralEventReaderOnce(); + SimblePeripheralManagerEntry *entry = simble_shadow_peripheral_manager_entry(); + // Mirror CoreBluetooth's async first state update: peripheralManagerDidUpdateState: on the + // manager's queue. + dispatchOnPeripheralQueue(entry, ^{ + id d = entry.delegate; + if ([d respondsToSelector:@selector(peripheralManagerDidUpdateState:)]) { + [d peripheralManagerDidUpdateState:manager]; + } + }); + } + return manager; +} + +- (instancetype)simble_initWithDelegate:(id)delegate + queue:(dispatch_queue_t)queue + options:(NSDictionary *)options { + CBPeripheralManager *manager = [self simble_initWithDelegate:delegate queue:queue options:options]; + if (manager) { + simble_shadow_register_peripheral_manager(manager, delegate, queue); + startPeripheralEventReaderOnce(); + SimblePeripheralManagerEntry *entry = simble_shadow_peripheral_manager_entry(); + dispatchOnPeripheralQueue(entry, ^{ + id d = entry.delegate; + if ([d respondsToSelector:@selector(peripheralManagerDidUpdateState:)]) { + [d peripheralManagerDidUpdateState:manager]; + } + }); + } + return manager; +} + +- (void)simble_addService:(CBMutableService *)service { + if (!simble_shadow_is_managed_peripheral_manager(self)) { + [self simble_addService:service]; + return; + } + // Route ADD_SERVICE, track the service so its characteristics resolve on later events, and + // deliver peripheralManager:didAddService:error:. + simble_shadow_track_service(service); + const char *svc = service.UUID.UUIDString.UTF8String; + NSMutableArray *held = [NSMutableArray array]; + const char **uuids = NULL; + size_t *lens = NULL; + uint64_t *props = NULL; + uint64_t *perms = NULL; + size_t count = buildCharacteristics(service, held, &uuids, &lens, &props, &perms); + simble_response resp; + simble_status st = simble_client_add_service(svc, strlen(svc), service.isPrimary ? 1 : 0, uuids, + lens, props, perms, count, &resp); + free(uuids); + free(lens); + free(props); + free(perms); + simble_internal_stats()->add_service++; + SimblePeripheralManagerEntry *entry = simble_shadow_peripheral_manager_entry(); + CBPeripheralManager *manager = self; + dispatchOnPeripheralQueue(entry, ^{ + id delegate = entry.delegate; + NSError *error = (st == SIMBLE_OK && resp.kind == SIMBLE_RESP_SERVICE) + ? nil + : [NSError errorWithDomain:CBErrorDomain code:resp.error_code userInfo:nil]; + if ([delegate respondsToSelector:@selector(peripheralManager:didAddService:error:)]) { + [delegate peripheralManager:manager didAddService:service error:error]; + } + }); +} + +- (void)simble_removeService:(CBMutableService *)service { + if (!simble_shadow_is_managed_peripheral_manager(self)) { + [self simble_removeService:service]; + return; + } + simble_shadow_untrack_service(service.UUID); + const char *svc = service.UUID.UUIDString.UTF8String; + simble_response resp; + simble_client_remove_service(svc, strlen(svc), &resp); + simble_internal_stats()->remove_service++; +} + +- (void)simble_removeAllServices { + if (!simble_shadow_is_managed_peripheral_manager(self)) { + [self simble_removeAllServices]; + return; + } + // Route a REMOVE_SERVICE for each tracked service, then drop the local tracking. + for (NSString *uuid in simble_shadow_tracked_service_uuids()) { + const char *svc = uuid.UTF8String; + simble_response resp; + simble_client_remove_service(svc, strlen(svc), &resp); + simble_shadow_untrack_service([CBUUID UUIDWithString:uuid]); + simble_internal_stats()->remove_service++; + } +} + +- (void)simble_startAdvertising:(NSDictionary *)advertisementData { + if (!simble_shadow_is_managed_peripheral_manager(self)) { + [self simble_startAdvertising:advertisementData]; + return; + } + // Route START_ADVERTISING with the local name and service UUIDs, then deliver + // peripheralManagerDidStartAdvertising:error:. + NSString *localName = advertisementData[CBAdvertisementDataLocalNameKey]; + const char *name = [localName isKindOfClass:NSString.class] ? localName.UTF8String : NULL; + NSArray *serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey]; + size_t count = [serviceUUIDs isKindOfClass:NSArray.class] ? serviceUUIDs.count : 0; + const char **uuids = count ? calloc(count, sizeof(char *)) : NULL; + size_t *lens = count ? calloc(count, sizeof(size_t)) : NULL; + NSMutableArray *held = [NSMutableArray arrayWithCapacity:count]; + for (size_t i = 0; i < count; i++) { + NSString *s = serviceUUIDs[i].UUIDString; + [held addObject:s]; + uuids[i] = s.UTF8String; + lens[i] = strlen(uuids[i]); + } + simble_response resp; + simble_status st = + simble_client_start_advertising(name, name ? strlen(name) : 0, uuids, lens, count, &resp); + free(uuids); + free(lens); + simble_internal_stats()->start_advertising++; + SimblePeripheralManagerEntry *entry = simble_shadow_peripheral_manager_entry(); + CBPeripheralManager *manager = self; + dispatchOnPeripheralQueue(entry, ^{ + id delegate = entry.delegate; + NSError *error = (st == SIMBLE_OK && resp.kind == SIMBLE_RESP_CONFIRMED) + ? nil + : [NSError errorWithDomain:CBErrorDomain code:resp.error_code userInfo:nil]; + if ([delegate respondsToSelector:@selector(peripheralManagerDidStartAdvertising:error:)]) { + [delegate peripheralManagerDidStartAdvertising:manager error:error]; + } + }); +} + +- (void)simble_stopAdvertising { + if (!simble_shadow_is_managed_peripheral_manager(self)) { + [self simble_stopAdvertising]; + return; + } + simble_response resp; + simble_client_stop_advertising(&resp); +} + +- (void)simble_respondToRequest:(CBATTRequest *)request withResult:(CBATTError)result { + uint64_t requestId = 0; + BOOL isWrite = NO; + if (!simble_shadow_request_id(request, &requestId, &isWrite)) { + [self simble_respondToRequest:request withResult:result]; + return; + } + // A read answer carries the value the guest set on the request; a write answer carries none. + simble_response resp; + if (isWrite) { + simble_client_respond_write(requestId, (uint64_t)result, &resp); + } else { + NSData *value = request.value; + simble_client_respond_read(requestId, value.bytes, value.length, (uint64_t)result, &resp); + } + simble_internal_stats()->respond++; +} + +- (BOOL)simble_updateValue:(NSData *)value + forCharacteristic:(CBMutableCharacteristic *)characteristic + onSubscribedCentrals:(NSArray *)centrals { + if (!simble_shadow_is_managed_peripheral_manager(self)) { + return [self simble_updateValue:value forCharacteristic:characteristic onSubscribedCentrals:centrals]; + } + // Resolve the service UUID from the characteristic's service, and the target central from the + // first entry when one is named. + CBUUID *serviceUUID = characteristic.service.UUID; + const char *svc = serviceUUID.UUIDString.UTF8String; + const char *chr = characteristic.UUID.UUIDString.UTF8String; + uint8_t cid[64]; + size_t cidLen = 0; + const uint8_t *central = NULL; + if (centrals.count && simble_shadow_central_id(centrals.firstObject, cid, sizeof(cid), &cidLen)) { + central = cid; + } + simble_response resp; + simble_status st = simble_client_update_value(svc, svc ? strlen(svc) : 0, chr, chr ? strlen(chr) : 0, + value.bytes, value.length, central, cidLen, &resp); + simble_internal_stats()->update_value++; + return st == SIMBLE_OK && resp.kind == SIMBLE_RESP_CONFIRMED; +} + +@end + +// --- Peripheral event delivery --- + +void simble_deliver_peripheral_event(const simble_event *event) { + SimblePeripheralManagerEntry *entry = simble_shadow_peripheral_manager_entry(); + if (!entry) + return; + CBPeripheralManager *manager = entry.manager; + + switch (event->kind) { + case SIMBLE_EVT_PERIPHERAL_STATE_CHANGED: { + dispatchOnPeripheralQueue(entry, ^{ + id delegate = entry.delegate; + if ([delegate respondsToSelector:@selector(peripheralManagerDidUpdateState:)]) { + [delegate peripheralManagerDidUpdateState:manager]; + } + }); + simble_internal_stats()->peripheral_event++; + break; + } + case SIMBLE_EVT_READ_REQUEST: + case SIMBLE_EVT_WRITE_REQUEST: { + BOOL isWrite = event->kind == SIMBLE_EVT_WRITE_REQUEST; + CBUUID *serviceUUID = [CBUUID UUIDWithString:[NSString stringWithUTF8String:event->service]]; + CBUUID *charUUID = + [CBUUID UUIDWithString:[NSString stringWithUTF8String:event->characteristic]]; + CBCharacteristic *characteristic = simble_shadow_tracked_characteristic(serviceUUID, charUUID); + CBCentral *central = simble_shadow_central(event->central, event->central_len, 0); + CBATTRequest *request = simble_shadow_att_request( + event->request_id, isWrite, characteristic, central, (NSUInteger)event->att_offset, + isWrite ? event->value : NULL, isWrite ? event->value_len : 0); + dispatchOnPeripheralQueue(entry, ^{ + id delegate = entry.delegate; + if (isWrite) { + if ([delegate respondsToSelector:@selector(peripheralManager:didReceiveWriteRequests:)]) { + [delegate peripheralManager:manager didReceiveWriteRequests:@[ request ]]; + } + } else if ([delegate respondsToSelector:@selector(peripheralManager:didReceiveReadRequest:)]) { + [delegate peripheralManager:manager didReceiveReadRequest:request]; + } + }); + simble_internal_stats()->peripheral_event++; + break; + } + case SIMBLE_EVT_SUBSCRIBED: + case SIMBLE_EVT_UNSUBSCRIBED: { + BOOL subscribed = event->kind == SIMBLE_EVT_SUBSCRIBED; + CBUUID *serviceUUID = [CBUUID UUIDWithString:[NSString stringWithUTF8String:event->service]]; + CBUUID *charUUID = + [CBUUID UUIDWithString:[NSString stringWithUTF8String:event->characteristic]]; + CBCharacteristic *characteristic = simble_shadow_tracked_characteristic(serviceUUID, charUUID); + CBCentral *central = + simble_shadow_central(event->central, event->central_len, (size_t)event->mtu); + dispatchOnPeripheralQueue(entry, ^{ + id delegate = entry.delegate; + if (subscribed) { + if ([delegate respondsToSelector:@selector + (peripheralManager:central:didSubscribeToCharacteristic:)]) { + [delegate peripheralManager:manager + central:central + didSubscribeToCharacteristic:characteristic]; + } + } else if ([delegate respondsToSelector:@selector + (peripheralManager:central:didUnsubscribeFromCharacteristic:)]) { + [delegate peripheralManager:manager + central:central + didUnsubscribeFromCharacteristic:characteristic]; + } + }); + simble_internal_stats()->peripheral_event++; + break; + } + case SIMBLE_EVT_READY_TO_UPDATE: { + dispatchOnPeripheralQueue(entry, ^{ + id delegate = entry.delegate; + if ([delegate respondsToSelector:@selector(peripheralManagerIsReadyToUpdateSubscribers:)]) { + [delegate peripheralManagerIsReadyToUpdateSubscribers:manager]; + } + }); + simble_internal_stats()->peripheral_event++; + break; + } + default: + break; + } +} + +int simble_install_peripheral_hooks(void) { + int failures = 0; + Class managerClass = [CBPeripheralManager class]; + failures += simble_swizzle(managerClass, @selector(initWithDelegate:queue:), + @selector(simble_initWithDelegate:queue:)); + failures += simble_swizzle(managerClass, @selector(initWithDelegate:queue:options:), + @selector(simble_initWithDelegate:queue:options:)); + failures += simble_swizzle(managerClass, @selector(addService:), @selector(simble_addService:)); + failures += + simble_swizzle(managerClass, @selector(removeService:), @selector(simble_removeService:)); + failures += simble_swizzle(managerClass, @selector(removeAllServices), + @selector(simble_removeAllServices)); + failures += simble_swizzle(managerClass, @selector(startAdvertising:), + @selector(simble_startAdvertising:)); + failures += + simble_swizzle(managerClass, @selector(stopAdvertising), @selector(simble_stopAdvertising)); + failures += simble_swizzle(managerClass, @selector(respondToRequest:withResult:), + @selector(simble_respondToRequest:withResult:)); + failures += simble_swizzle(managerClass, + @selector(updateValue:forCharacteristic:onSubscribedCentrals:), + @selector(simble_updateValue:forCharacteristic:onSubscribedCentrals:)); + return failures; +} diff --git a/packages/interpose/src/registry/shadow.h b/packages/interpose/src/registry/shadow.h index 8f7fbc2..c31725f 100644 --- a/packages/interpose/src/registry/shadow.h +++ b/packages/interpose/src/registry/shadow.h @@ -167,6 +167,133 @@ BOOL simble_shadow_resolve_characteristic(CBCharacteristic *characteristic, uint CBUUID *_Nullable *_Nonnull serviceUUID, CBUUID *_Nullable *_Nonnull characteristicUUID); +/// A registered peripheral manager: the manager to pass back, its delegate, and the queue. +@interface SimblePeripheralManagerEntry : NSObject +/// The manager the guest created, weakly held, passed as the first delegate-callback argument. +@property(nonatomic, weak, nullable) CBPeripheralManager *manager; +/// The delegate the guest passed to initWithDelegate:queue:, weakly held as CoreBluetooth does. +@property(nonatomic, weak, nullable) id delegate; +/// The dispatch queue the guest gave its manager; nil means the main queue, as CoreBluetooth does. +@property(nonatomic, strong, nullable) dispatch_queue_t queue; +@end + +/** + * @brief Register a CBPeripheralManager the guest created while the interposer is active. + * + * @param[in] manager The manager instance. + * @param[in] delegate The delegate it was created with, or nil. + * @param[in] queue The dispatch queue it was created with, or nil for the main queue. + */ +void simble_shadow_register_peripheral_manager(CBPeripheralManager *manager, + id _Nullable delegate, + dispatch_queue_t _Nullable queue); + +/** + * @brief Whether a CBPeripheralManager is one the interposer manages. + * + * @param[in] manager The manager to test. + * @return YES if registered, NO otherwise. + */ +BOOL simble_shadow_is_managed_peripheral_manager(CBPeripheralManager *manager); + +/** + * @brief The sole registered peripheral manager, or nil if none is registered. + * + * The peripheral GATT database is process-wide, so events carry no manager and resolve to the one + * registered manager. + * + * @return The entry, or nil. + */ +SimblePeripheralManagerEntry *_Nullable simble_shadow_peripheral_manager_entry(void); + +/** + * @brief Record a CBMutableService and its characteristics, keyed by UUID. + * + * A later event carrying a service and characteristic UUID resolves back to the guest's own + * CBMutableCharacteristic through this record. + * + * @param[in] service The service the guest passed to addService:. + */ +void simble_shadow_track_service(CBMutableService *service); + +/** + * @brief Drop a tracked service and its characteristics. + * + * @param[in] serviceUUID The service UUID to drop. + */ +void simble_shadow_untrack_service(CBUUID *serviceUUID); + +/** + * @brief The UUID strings of every tracked service. + * + * @return A snapshot array of the tracked service UUID strings. + */ +NSArray *simble_shadow_tracked_service_uuids(void); + +/** + * @brief The tracked characteristic for a service and characteristic UUID, or nil. + * + * @param[in] serviceUUID The service UUID. + * @param[in] characteristicUUID The characteristic UUID. + * @return The guest's CBMutableCharacteristic, or nil if it was never tracked. + */ +CBMutableCharacteristic *_Nullable simble_shadow_tracked_characteristic(CBUUID *serviceUUID, + CBUUID *characteristicUUID); + +/** + * @brief Mint or return the stand-in central for a host identifier. + * + * The first call for an identifier mints a CBCentral stand-in and records it; a later call for the + * same identifier returns the same stand-in. + * + * @param[in] centralId The host central identifier bytes. + * @param[in] centralLen Length of @p centralId. + * @param[in] mtu The central's maximumUpdateValueLength. + * @return The stand-in central, or nil if the identifier was empty. + */ +CBCentral *_Nullable simble_shadow_central(const uint8_t *centralId, size_t centralLen, size_t mtu); + +/** + * @brief Copy the host identifier bytes of a minted central. + * + * @param[in] central The minted central. + * @param[out] out Buffer the identifier is copied into. + * @param[in] cap Capacity of @p out. + * @param[out] outLen Bytes written to @p out. + * @return YES on a hit, NO if @p central was not minted here or @p cap is too small. + */ +BOOL simble_shadow_central_id(CBCentral *central, uint8_t *out, size_t cap, size_t *outLen); + +/** + * @brief Mint a stand-in ATT request carrying its request id, characteristic, and central. + * + * The guest answers the request by calling respondToRequest:withResult: on the stand-in, which + * carries the request id back through ::simble_shadow_request_id. + * + * @param[in] requestId The request id from the READ_REQUEST or WRITE_REQUEST event. + * @param[in] isWrite Non-zero for a write request. + * @param[in] characteristic The guest's tracked characteristic for the request. + * @param[in] central The stand-in central that originated the request. + * @param[in] offset The ATT offset. + * @param[in] value The write value bytes, or NULL for a read. + * @param[in] valueLen Length of @p value; 0 for a read. + * @return The stand-in request. + */ +CBATTRequest *simble_shadow_att_request(uint64_t requestId, BOOL isWrite, + CBCharacteristic *_Nullable characteristic, + CBCentral *_Nullable central, NSUInteger offset, + const uint8_t *_Nullable value, size_t valueLen); + +/** + * @brief Resolve a minted ATT request to its request id and whether it was a write. + * + * @param[in] request The minted request. + * @param[out] requestId Set to the request id. + * @param[out] isWrite Set to non-zero for a write request. + * @return YES on a hit, NO if @p request was not minted here. + */ +BOOL simble_shadow_request_id(CBATTRequest *request, uint64_t *requestId, BOOL *isWrite); + /** * @brief Drop every registration. * diff --git a/packages/interpose/src/registry/shadow.m b/packages/interpose/src/registry/shadow.m index 6cce46d..20cad81 100644 --- a/packages/interpose/src/registry/shadow.m +++ b/packages/interpose/src/registry/shadow.m @@ -27,6 +27,9 @@ @implementation SimbleManagerEntry @end +@implementation SimblePeripheralManagerEntry +@end + // Identity attached to a minted peripheral, service, or characteristic. A minted object // carries one of these as associated state; absence of it is what "not managed" means. @interface SimbleShadowMeta : NSObject @@ -36,7 +39,15 @@ @interface SimbleShadowMeta : NSObject @property(nonatomic, weak, nullable) CBCentralManager *owner; // owning manager (peripheral) @property(nonatomic, weak, nullable) CBPeripheral *peripheral; // owning peripheral (service) @property(nonatomic, strong, nullable) - NSMutableArray *children; // attached services or characteristics + NSMutableArray *children; // attached services or characteristics +@property(nonatomic, strong, nullable) NSUUID *identifier; // central identifier (central) +@property(nonatomic, assign) NSUInteger mtu; // maximumUpdateValueLength (central) +@property(nonatomic, assign) uint64_t requestId; // request id (ATT request) +@property(nonatomic, assign) BOOL isWrite; // write request flag (ATT request) +@property(nonatomic, assign) NSUInteger offset; // ATT offset (ATT request) +@property(nonatomic, strong, nullable) NSData *value; // read/write value (ATT request) +@property(nonatomic, strong, nullable) CBCharacteristic *characteristic; // request char (ATT request) +@property(nonatomic, strong, nullable) CBCentral *central; // origin central (ATT request) @end @implementation SimbleShadowMeta @@ -63,6 +74,16 @@ @implementation SimbleShadowMeta // The set of minted object pointers, the membership authority for the fail-closed check. static NSMutableSet *g_minted; +// The registered peripheral managers, keyed by the manager pointer; the value is the +// SimblePeripheralManagerEntry. One GATT database per process; events resolve to the latest entry. +static NSMutableDictionary *g_peripheral_managers; +// The guest's tracked CBMutableCharacteristics, keyed by ":". +static NSMutableDictionary *g_tracked_characteristics; +// The characteristic UUID strings of each tracked service, keyed by service UUID, for untracking. +static NSMutableDictionary *> *g_tracked_services; +// Minted centrals, keyed by the central id hex, so one stand-in is returned per identifier. +static NSMutableDictionary *g_centrals; + // A no-op dealloc, installed on each stand-in subclass. A bare instance of a CoreBluetooth class // was never run through CoreBluetooth's own initializer, so the framework's dealloc (which tears // down KVO registrations the initializer made) must not run on it; this leaves the object's own @@ -77,6 +98,15 @@ static void shadowDealloc(id self, SEL _cmd) { // stand-in never had, so the stand-in answers from the registry instead. static id shadowChildren(id self, SEL _cmd); +// Stand-in property getters, each answering from the attached meta in place of a private ivar. +static id shadowIdentifier(id self, SEL _cmd); +static NSUInteger shadowMtu(id self, SEL _cmd); +static id shadowCentral(id self, SEL _cmd); +static id shadowCharacteristic(id self, SEL _cmd); +static NSUInteger shadowOffset(id self, SEL _cmd); +static id shadowValue(id self, SEL _cmd); +static void shadowSetValue(id self, SEL _cmd, id value); + // Build a stand-in subclass of a CoreBluetooth class with the no-op dealloc, once per class. static Class makeShadowSubclass(Class base, const char *name) { Class cls = objc_allocateClassPair(base, name, 0); @@ -88,6 +118,8 @@ static Class makeShadowSubclass(Class base, const char *name) { static Class g_peripheralShadowClass; static Class g_serviceShadowClass; static Class g_characteristicShadowClass; +static Class g_centralShadowClass; +static Class g_attRequestShadowClass; __attribute__((constructor)) static void simble_shadow_init(void) { g_lock = [NSLock new]; @@ -96,16 +128,36 @@ static Class makeShadowSubclass(Class base, const char *name) { g_services = [NSMutableDictionary new]; g_characteristics = [NSMutableDictionary new]; g_minted = [NSMutableSet new]; + g_peripheral_managers = [NSMutableDictionary new]; + g_tracked_characteristics = [NSMutableDictionary new]; + g_tracked_services = [NSMutableDictionary new]; + g_centrals = [NSMutableDictionary new]; g_peripheralShadowClass = makeShadowSubclass([CBPeripheral class], "SimbleShadowPeripheral"); g_serviceShadowClass = makeShadowSubclass([CBService class], "SimbleShadowService"); g_characteristicShadowClass = makeShadowSubclass([CBCharacteristic class], "SimbleShadowCharacteristic"); + g_centralShadowClass = makeShadowSubclass([CBCentral class], "SimbleShadowCentral"); + g_attRequestShadowClass = makeShadowSubclass([CBATTRequest class], "SimbleShadowATTRequest"); // The stand-in peripheral's services and the stand-in service's characteristics answer from the // registry's attached child list. class_addMethod(g_peripheralShadowClass, sel_registerName("services"), (IMP)shadowChildren, "@@:"); class_addMethod(g_serviceShadowClass, sel_registerName("characteristics"), (IMP)shadowChildren, "@@:"); + // The stand-in central answers its identifier and maximumUpdateValueLength from the meta. + class_addMethod(g_centralShadowClass, sel_registerName("identifier"), (IMP)shadowIdentifier, + "@@:"); + class_addMethod(g_centralShadowClass, sel_registerName("maximumUpdateValueLength"), + (IMP)shadowMtu, "L@:"); + // The stand-in ATT request answers central, characteristic, offset, and value from the meta, and + // its value setter records the read answer the guest sets before responding. + class_addMethod(g_attRequestShadowClass, sel_registerName("central"), (IMP)shadowCentral, "@@:"); + class_addMethod(g_attRequestShadowClass, sel_registerName("characteristic"), + (IMP)shadowCharacteristic, "@@:"); + class_addMethod(g_attRequestShadowClass, sel_registerName("offset"), (IMP)shadowOffset, "L@:"); + class_addMethod(g_attRequestShadowClass, sel_registerName("value"), (IMP)shadowValue, "@@:"); + class_addMethod(g_attRequestShadowClass, sel_registerName("setValue:"), (IMP)shadowSetValue, + "v@:@"); } static NSValue *ptr(id object) { return [NSValue valueWithPointer:(__bridge const void *)object]; } @@ -134,6 +186,41 @@ static id shadowChildren(id self, SEL _cmd) { return snapshot; } +static id shadowIdentifier(id self, SEL _cmd) { + (void)_cmd; + return metaOf(self).identifier; +} + +static NSUInteger shadowMtu(id self, SEL _cmd) { + (void)_cmd; + return metaOf(self).mtu; +} + +static id shadowCentral(id self, SEL _cmd) { + (void)_cmd; + return metaOf(self).central; +} + +static id shadowCharacteristic(id self, SEL _cmd) { + (void)_cmd; + return metaOf(self).characteristic; +} + +static NSUInteger shadowOffset(id self, SEL _cmd) { + (void)_cmd; + return metaOf(self).offset; +} + +static id shadowValue(id self, SEL _cmd) { + (void)_cmd; + return metaOf(self).value; +} + +static void shadowSetValue(id self, SEL _cmd, id value) { + (void)_cmd; + metaOf(self).value = value; +} + void simble_shadow_register_manager(CBCentralManager *manager, id delegate, dispatch_queue_t queue) { if (!manager) @@ -319,6 +406,157 @@ BOOL simble_shadow_resolve_characteristic(CBCharacteristic *characteristic, uint return YES; } +void simble_shadow_register_peripheral_manager(CBPeripheralManager *manager, + id delegate, + dispatch_queue_t queue) { + if (!manager) + return; + SimblePeripheralManagerEntry *entry = [SimblePeripheralManagerEntry new]; + entry.manager = manager; + entry.delegate = delegate; + entry.queue = queue; + [g_lock lock]; + g_peripheral_managers[ptr(manager)] = entry; + [g_lock unlock]; +} + +BOOL simble_shadow_is_managed_peripheral_manager(CBPeripheralManager *manager) { + if (!manager) + return NO; + [g_lock lock]; + BOOL found = g_peripheral_managers[ptr(manager)] != nil; + [g_lock unlock]; + return found; +} + +SimblePeripheralManagerEntry *simble_shadow_peripheral_manager_entry(void) { + [g_lock lock]; + SimblePeripheralManagerEntry *entry = g_peripheral_managers.allValues.lastObject; + [g_lock unlock]; + return entry; +} + +void simble_shadow_track_service(CBMutableService *service) { + if (!service) + return; + NSString *svc = service.UUID.UUIDString; + [g_lock lock]; + NSMutableArray *charUUIDs = [NSMutableArray new]; + for (CBCharacteristic *characteristic in service.characteristics) { + if (![characteristic isKindOfClass:CBMutableCharacteristic.class]) + continue; + NSString *key = [NSString stringWithFormat:@"%@:%@", svc, characteristic.UUID.UUIDString]; + g_tracked_characteristics[key] = (CBMutableCharacteristic *)characteristic; + [charUUIDs addObject:characteristic.UUID.UUIDString]; + } + g_tracked_services[svc] = charUUIDs; + [g_lock unlock]; +} + +void simble_shadow_untrack_service(CBUUID *serviceUUID) { + if (!serviceUUID) + return; + NSString *svc = serviceUUID.UUIDString; + [g_lock lock]; + for (NSString *charUUID in g_tracked_services[svc]) { + [g_tracked_characteristics removeObjectForKey:[NSString stringWithFormat:@"%@:%@", svc, + charUUID]]; + } + [g_tracked_services removeObjectForKey:svc]; + [g_lock unlock]; +} + +NSArray *simble_shadow_tracked_service_uuids(void) { + [g_lock lock]; + NSArray *snapshot = g_tracked_services.allKeys; + [g_lock unlock]; + return snapshot; +} + +CBMutableCharacteristic *simble_shadow_tracked_characteristic(CBUUID *serviceUUID, + CBUUID *characteristicUUID) { + if (!serviceUUID || !characteristicUUID) + return nil; + NSString *key = + [NSString stringWithFormat:@"%@:%@", serviceUUID.UUIDString, characteristicUUID.UUIDString]; + [g_lock lock]; + CBMutableCharacteristic *characteristic = g_tracked_characteristics[key]; + [g_lock unlock]; + return characteristic; +} + +CBCentral *simble_shadow_central(const uint8_t *centralId, size_t centralLen, size_t mtu) { + if (!centralId || centralLen == 0) + return nil; + NSString *key = hexOf(centralId, centralLen); + [g_lock lock]; + CBCentral *existing = g_centrals[key]; + if (existing) { + [g_lock unlock]; + return existing; + } + CBCentral *minted = mintInstance(g_centralShadowClass); + SimbleShadowMeta *meta = [SimbleShadowMeta new]; + meta.peripheralId = [NSData dataWithBytes:centralId length:centralLen]; + // A stable per-id UUID for the stand-in's identifier, derived from the id hex. + NSUUID *uuid = [[NSUUID alloc] initWithUUIDString:key]; + meta.identifier = uuid ?: [NSUUID UUID]; + meta.mtu = mtu; + objc_setAssociatedObject(minted, kShadowMetaKey, meta, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + g_centrals[key] = minted; + [g_minted addObject:ptr(minted)]; + [g_lock unlock]; + return minted; +} + +BOOL simble_shadow_central_id(CBCentral *central, uint8_t *out, size_t cap, size_t *outLen) { + if (!central) + return NO; + [g_lock lock]; + BOOL minted = [g_minted containsObject:ptr(central)]; + [g_lock unlock]; + if (!minted) + return NO; + NSData *cidData = metaOf(central).peripheralId; + if (!cidData || (size_t)cidData.length > cap) + return NO; + memcpy(out, cidData.bytes, cidData.length); + *outLen = cidData.length; + return YES; +} + +CBATTRequest *simble_shadow_att_request(uint64_t requestId, BOOL isWrite, + CBCharacteristic *characteristic, CBCentral *central, + NSUInteger offset, const uint8_t *value, size_t valueLen) { + CBATTRequest *minted = mintInstance(g_attRequestShadowClass); + SimbleShadowMeta *meta = [SimbleShadowMeta new]; + meta.requestId = requestId; + meta.isWrite = isWrite; + meta.characteristic = characteristic; + meta.central = central; + meta.offset = offset; + meta.value = value ? [NSData dataWithBytes:value length:valueLen] : nil; + objc_setAssociatedObject(minted, kShadowMetaKey, meta, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [g_lock lock]; + [g_minted addObject:ptr(minted)]; + [g_lock unlock]; + return minted; +} + +BOOL simble_shadow_request_id(CBATTRequest *request, uint64_t *requestId, BOOL *isWrite) { + if (!request) + return NO; + [g_lock lock]; + BOOL minted = [g_minted containsObject:ptr(request)]; + [g_lock unlock]; + if (!minted) + return NO; + SimbleShadowMeta *meta = metaOf(request); + *requestId = meta.requestId; + *isWrite = meta.isWrite; + return YES; +} + void simble_shadow_reset(void) { [g_lock lock]; [g_managers removeAllObjects]; @@ -326,5 +564,9 @@ void simble_shadow_reset(void) { [g_services removeAllObjects]; [g_characteristics removeAllObjects]; [g_minted removeAllObjects]; + [g_peripheral_managers removeAllObjects]; + [g_tracked_characteristics removeAllObjects]; + [g_tracked_services removeAllObjects]; + [g_centrals removeAllObjects]; [g_lock unlock]; } diff --git a/packages/interpose/src/transport/client.c b/packages/interpose/src/transport/client.c index 498798f..4454b37 100644 --- a/packages/interpose/src/transport/client.c +++ b/packages/interpose/src/transport/client.c @@ -293,6 +293,90 @@ simble_status simble_client_read_rssi(const uint8_t *peripheral_id, size_t perip return do_request(payload, n, out); } +simble_status simble_client_add_service(const char *service, size_t service_len, int is_primary, + const char *const *char_uuids, const size_t *char_uuid_lens, + const uint64_t *properties, const uint64_t *permissions, + size_t char_count, simble_response *out) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t payload[4096]; + int n = simble_encode_add_service(token, sizeof(token), service, service_len, is_primary, + char_uuids, char_uuid_lens, properties, permissions, char_count, + payload, sizeof(payload)); + return do_request(payload, n, out); +} + +simble_status simble_client_remove_service(const char *service, size_t service_len, + simble_response *out) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t payload[256]; + int n = simble_encode_remove_service(token, sizeof(token), service, service_len, payload, + sizeof(payload)); + return do_request(payload, n, out); +} + +simble_status simble_client_start_advertising(const char *local_name, size_t local_name_len, + const char *const *uuids, const size_t *uuid_lens, + size_t uuid_count, simble_response *out) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t payload[1024]; + int n = simble_encode_start_advertising(token, sizeof(token), local_name, local_name_len, uuids, + uuid_lens, uuid_count, payload, sizeof(payload)); + return do_request(payload, n, out); +} + +simble_status simble_client_stop_advertising(simble_response *out) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t payload[64]; + // STOP_ADVERTISING is op 17, a token-only command. + int n = simble_encode_command(17, token, sizeof(token), payload, sizeof(payload)); + return do_request(payload, n, out); +} + +simble_status simble_client_respond_read(uint64_t request_id, const uint8_t *value, size_t value_len, + uint64_t att_error, simble_response *out) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t payload[2048]; + int n = simble_encode_respond_read(token, sizeof(token), request_id, value, value_len, att_error, + payload, sizeof(payload)); + return do_request(payload, n, out); +} + +simble_status simble_client_respond_write(uint64_t request_id, uint64_t att_error, + simble_response *out) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t payload[256]; + int n = simble_encode_respond_write(token, sizeof(token), request_id, att_error, payload, + sizeof(payload)); + return do_request(payload, n, out); +} + +simble_status simble_client_update_value(const char *service, size_t service_len, + const char *characteristic, size_t char_len, + const uint8_t *value, size_t value_len, + const uint8_t *central_id, size_t central_len, + simble_response *out) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t payload[2048]; + int n = simble_encode_update_value(token, sizeof(token), service, service_len, characteristic, + char_len, value, value_len, central_id, central_len, payload, + sizeof(payload)); + return do_request(payload, n, out); +} + simble_status simble_client_open(simble_conn *conn) { uint8_t token[32]; if (read_token(token) != 0) diff --git a/packages/interpose/src/transport/client.h b/packages/interpose/src/transport/client.h index 6ae7fff..0cd7c29 100644 --- a/packages/interpose/src/transport/client.h +++ b/packages/interpose/src/transport/client.h @@ -216,6 +216,106 @@ simble_status simble_client_set_notify(const uint8_t *peripheral_id, size_t peri simble_status simble_client_read_rssi(const uint8_t *peripheral_id, size_t peripheral_len, simble_response *out); +/** + * @brief Publish a GATT service with its characteristics. + * + * The characteristic UUIDs, properties, and permissions are three parallel arrays of @p char_count + * entries, one per characteristic. + * + * @param[in] service Service UUID, UTF-8. + * @param[in] service_len Length of @p service. + * @param[in] is_primary Non-zero marks the service primary. + * @param[in] char_uuids Characteristic UUID strings. + * @param[in] char_uuid_lens Per-UUID lengths, parallel to @p char_uuids. + * @param[in] properties Per-characteristic CBCharacteristicProperties. + * @param[in] permissions Per-characteristic CBAttributePermissions. + * @param[in] char_count Characteristic count. + * @param[out] out The decoded response; ::SIMBLE_RESP_SERVICE on success. + * @return ::SIMBLE_OK when a response decoded cleanly, a ::simble_status error otherwise. + */ +simble_status simble_client_add_service(const char *service, size_t service_len, int is_primary, + const char *const *char_uuids, const size_t *char_uuid_lens, + const uint64_t *properties, const uint64_t *permissions, + size_t char_count, simble_response *out); + +/** + * @brief Remove a published service named by its UUID. + * + * @param[in] service Service UUID, UTF-8. + * @param[in] service_len Length of @p service. + * @param[out] out The decoded response; ::SIMBLE_RESP_SERVICE on success. + * @return ::SIMBLE_OK when a response decoded cleanly, a ::simble_status error otherwise. + */ +simble_status simble_client_remove_service(const char *service, size_t service_len, + simble_response *out); + +/** + * @brief Begin advertising, optionally a local name and a set of service UUIDs. + * + * @param[in] local_name Advertised local name, UTF-8, or NULL. + * @param[in] local_name_len Length of @p local_name; 0 omits it. + * @param[in] uuids Advertised service UUID strings, or NULL. + * @param[in] uuid_lens Per-UUID lengths, parallel to @p uuids. + * @param[in] uuid_count Number of UUIDs; 0 omits the list. + * @param[out] out The decoded response; ::SIMBLE_RESP_CONFIRMED on success. + * @return ::SIMBLE_OK when a response decoded cleanly, a ::simble_status error otherwise. + */ +simble_status simble_client_start_advertising(const char *local_name, size_t local_name_len, + const char *const *uuids, const size_t *uuid_lens, + size_t uuid_count, simble_response *out); + +/** + * @brief End advertising. + * + * @param[out] out The decoded response; ::SIMBLE_RESP_CONFIRMED on success. + * @return ::SIMBLE_OK when a response decoded cleanly, a ::simble_status error otherwise. + */ +simble_status simble_client_stop_advertising(simble_response *out); + +/** + * @brief Answer an incoming read request with a value and an ATT result. + * + * @param[in] request_id Request id from the READ_REQUEST event. + * @param[in] value Value bytes to return. + * @param[in] value_len Length of @p value. + * @param[in] att_error ATT result code; 0 is success. + * @param[out] out The decoded response; ::SIMBLE_RESP_CONFIRMED on success. + * @return ::SIMBLE_OK when a response decoded cleanly, a ::simble_status error otherwise. + */ +simble_status simble_client_respond_read(uint64_t request_id, const uint8_t *value, size_t value_len, + uint64_t att_error, simble_response *out); + +/** + * @brief Answer an incoming write request with an ATT result. + * + * @param[in] request_id Request id from the WRITE_REQUEST event. + * @param[in] att_error ATT result code; 0 is success. + * @param[out] out The decoded response; ::SIMBLE_RESP_CONFIRMED on success. + * @return ::SIMBLE_OK when a response decoded cleanly, a ::simble_status error otherwise. + */ +simble_status simble_client_respond_write(uint64_t request_id, uint64_t att_error, + simble_response *out); + +/** + * @brief Push a new characteristic value to subscribers. + * + * @param[in] service Service UUID, UTF-8. + * @param[in] service_len Length of @p service. + * @param[in] characteristic Characteristic UUID, UTF-8. + * @param[in] char_len Length of @p characteristic. + * @param[in] value New value bytes. + * @param[in] value_len Length of @p value. + * @param[in] central_id Target central id, or NULL to reach every subscriber. + * @param[in] central_len Length of @p central_id; 0 omits it. + * @param[out] out The decoded response; ::SIMBLE_RESP_CONFIRMED on success. + * @return ::SIMBLE_OK when a response decoded cleanly, a ::simble_status error otherwise. + */ +simble_status simble_client_update_value(const char *service, size_t service_len, + const char *characteristic, size_t char_len, + const uint8_t *value, size_t value_len, + const uint8_t *central_id, size_t central_len, + simble_response *out); + /** * @brief Open a connection to the helper, for a path that reads events after sending a request. * diff --git a/packages/interpose/tests/client_roundtrip.c b/packages/interpose/tests/client_roundtrip.c index 57835e9..2f30a34 100644 --- a/packages/interpose/tests/client_roundtrip.c +++ b/packages/interpose/tests/client_roundtrip.c @@ -216,6 +216,22 @@ static size_t build_response(uint64_t op, uint8_t *out) { n += put_head(out + n, 0, 34); n += put_head(out + n, 1, 49); // CBOR negint 49 encodes -50 return n; + case 14: // ADD_SERVICE: { 0:14, 1:0, 31:svc } + case 15: // REMOVE_SERVICE: { 0:15, 1:0, 31:svc } + n += put_head(out + n, 5, 3); + n += put_kv_uint(out + n, 0, op); + n += put_kv_uint(out + n, 1, 0); + n += put_kv_text(out + n, 31, "180D"); + return n; + case 16: // START_ADVERTISING: { 0:16, 1:0 } + case 17: // STOP_ADVERTISING: { 0:17, 1:0 } + case 18: // RESPOND_READ: { 0:18, 1:0 } + case 19: // RESPOND_WRITE: { 0:19, 1:0 } + case 20: // UPDATE_VALUE: { 0:20, 1:0 } + n += put_head(out + n, 5, 2); + n += put_kv_uint(out + n, 0, op); + n += put_kv_uint(out + n, 1, 0); + return n; default: return 0; } @@ -263,7 +279,7 @@ static void *server_thread(void *arg) { pthread_mutex_unlock(&ctx->lock); // Serve one connection per directed request, then one connection that carries an event. - for (int i = 0; i < 10; i++) { + for (int i = 0; i < 17; i++) { int fd = accept(listen_fd, NULL, NULL); if (fd < 0) break; @@ -352,6 +368,38 @@ int main(void) { resp.kind == SIMBLE_RESP_PERIPHERAL, "disconnect echoes the peripheral id"); + // The peripheral one-shot requests: publish, advertise, respond, notify. + const char *charUUID = "2A37"; + const size_t charLen = 4; + const uint64_t props[] = {0x02}; // CBCharacteristicPropertyRead + const uint64_t perms[] = {0x01}; // CBAttributePermissionsReadable + CHECK(simble_client_add_service("180D", 4, 1, &charUUID, &charLen, props, perms, 1, &resp) == + SIMBLE_OK && resp.kind == SIMBLE_RESP_SERVICE, + "add service confirmed"); + CHECK(simble_client_remove_service("180D", 4, &resp) == SIMBLE_OK && + resp.kind == SIMBLE_RESP_SERVICE, + "remove service confirmed"); + + const char *advUUID = "180D"; + const size_t advLen = 4; + CHECK(simble_client_start_advertising("Sim", 3, &advUUID, &advLen, 1, &resp) == SIMBLE_OK && + resp.kind == SIMBLE_RESP_CONFIRMED, + "start advertising confirmed"); + CHECK(simble_client_stop_advertising(&resp) == SIMBLE_OK && resp.kind == SIMBLE_RESP_CONFIRMED, + "stop advertising confirmed"); + + const uint8_t readValue[] = {0x42}; + CHECK(simble_client_respond_read(7, readValue, sizeof(readValue), 0, &resp) == SIMBLE_OK && + resp.kind == SIMBLE_RESP_CONFIRMED, + "respond read confirmed"); + CHECK(simble_client_respond_write(8, 0, &resp) == SIMBLE_OK && resp.kind == SIMBLE_RESP_CONFIRMED, + "respond write confirmed"); + + const uint8_t notifyValue[] = {0x48, 0x69}; + CHECK(simble_client_update_value("180D", 4, "2A37", 4, notifyValue, sizeof(notifyValue), NULL, 0, + &resp) == SIMBLE_OK && resp.kind == SIMBLE_RESP_CONFIRMED, + "update value confirmed"); + // The event stream: open a connection and read one DISCOVERED event. simble_conn conn; CHECK(simble_client_open(&conn) == SIMBLE_OK, "open the event connection"); diff --git a/packages/interpose/tests/hook_smoke.c b/packages/interpose/tests/hook_smoke.c index 1076f0e..4273a73 100644 --- a/packages/interpose/tests/hook_smoke.c +++ b/packages/interpose/tests/hook_smoke.c @@ -9,8 +9,9 @@ * * @details * The host has the CoreBluetooth classes the swizzles target, so a plain ctest can install - * every swizzle, confirm none failed, and uninstall, with no radio and no helper. The - * version entry point is checked too. + * every central and peripheral swizzle, confirm none failed, and uninstall, with no radio and no + * helper. The version entry point is checked too. A non-zero install count means some selector, + * central or peripheral, did not swizzle. * * @author Nirapod Labs * @date 2026 @@ -24,6 +25,11 @@ int main(void) { assert(simble_interpose_version() == 1); + // The counters start at zero before any routed call. + simble_hook_stats fresh = simble_get_hook_stats(); + assert(fresh.add_service == 0 && fresh.start_advertising == 0 && fresh.respond == 0 && + fresh.update_value == 0 && fresh.peripheral_event == 0); + int failures = simble_install_hooks(); printf("install: %d swizzle(s) failed\n", failures); if (failures != 0) diff --git a/packages/interpose/tests/passthrough_test.m b/packages/interpose/tests/passthrough_test.m index 7704c6f..ac6139d 100644 --- a/packages/interpose/tests/passthrough_test.m +++ b/packages/interpose/tests/passthrough_test.m @@ -59,6 +59,25 @@ int main(void) { simble_hook_stats end = simble_get_hook_stats(); CHECK(start.scan_start == end.scan_start, "a non-managed scan does not count as routed"); + // The peripheral surface gates on the same registry predicate: a stray peripheral manager is + // not managed, a registered one is. + CBPeripheralManager *strayPeripheral = [CBPeripheralManager alloc]; + CHECK(!simble_shadow_is_managed_peripheral_manager(strayPeripheral), + "a stray peripheral manager is not managed"); + + // A managed peripheral manager routes addService:, moving the counter. The transport call fails + // closed without a helper; the route still fired. + CBMutableService *service = + [[CBMutableService alloc] initWithType:[CBUUID UUIDWithString:@"180D"] primary:YES]; + CBPeripheralManager *managedPeripheral = [CBPeripheralManager alloc]; + simble_shadow_register_peripheral_manager(managedPeripheral, nil, nil); + CHECK(simble_shadow_is_managed_peripheral_manager(managedPeripheral), + "a registered peripheral manager is managed"); + simble_hook_stats pStart = simble_get_hook_stats(); + [managedPeripheral addService:service]; + simble_hook_stats pManaged = simble_get_hook_stats(); + CHECK(pManaged.add_service == pStart.add_service + 1, "a managed addService counts as routed"); + simble_uninstall_hooks(); simble_shadow_reset(); } diff --git a/packages/interpose/tests/shadow_registry_test.m b/packages/interpose/tests/shadow_registry_test.m index 6d89f62..7ca92c6 100644 --- a/packages/interpose/tests/shadow_registry_test.m +++ b/packages/interpose/tests/shadow_registry_test.m @@ -94,8 +94,71 @@ int main(void) { CHECK(simble_shadow_service((CBPeripheral *)manager, serviceUUID) == nil, "a service on a non-minted peripheral fails closed"); + // A peripheral manager is managed once registered, and resolves to the latest entry. + CBPeripheralManager *pManager = [CBPeripheralManager alloc]; + CHECK(!simble_shadow_is_managed_peripheral_manager(pManager), + "an unregistered peripheral manager is not managed"); + simble_shadow_register_peripheral_manager(pManager, nil, nil); + CHECK(simble_shadow_is_managed_peripheral_manager(pManager), + "a registered peripheral manager is managed"); + CHECK(simble_shadow_peripheral_manager_entry() != nil, + "the registered peripheral manager entry resolves"); + + // A tracked service resolves its characteristics back by UUID. + CBUUID *pServiceUUID = [CBUUID UUIDWithString:@"180F"]; + CBUUID *pCharUUID = [CBUUID UUIDWithString:@"2A19"]; + CBMutableCharacteristic *mutableChar = + [[CBMutableCharacteristic alloc] initWithType:pCharUUID + properties:CBCharacteristicPropertyRead + value:nil + permissions:CBAttributePermissionsReadable]; + CBMutableService *mutableService = [[CBMutableService alloc] initWithType:pServiceUUID + primary:YES]; + mutableService.characteristics = @[ mutableChar ]; + simble_shadow_track_service(mutableService); + CHECK(simble_shadow_tracked_characteristic(pServiceUUID, pCharUUID) == mutableChar, + "a tracked characteristic resolves by service and characteristic uuid"); + CHECK([simble_shadow_tracked_service_uuids() containsObject:pServiceUUID.UUIDString], + "a tracked service uuid is listed"); + simble_shadow_untrack_service(pServiceUUID); + CHECK(simble_shadow_tracked_characteristic(pServiceUUID, pCharUUID) == nil, + "an untracked characteristic no longer resolves"); + + // One central stand-in per host identifier, and the id round-trips. + const uint8_t centralBytes[] = {0xca, 0xfe}; + CBCentral *c1 = simble_shadow_central(centralBytes, sizeof(centralBytes), 185); + CBCentral *c2 = simble_shadow_central(centralBytes, sizeof(centralBytes), 185); + CHECK(c1 != nil && c1 == c2, "one central stand-in per identifier"); + uint8_t cidOut[64]; + size_t cidLen = 0; + CHECK(simble_shadow_central_id(c1, cidOut, sizeof(cidOut), &cidLen) && + cidLen == sizeof(centralBytes) && memcmp(cidOut, centralBytes, cidLen) == 0, + "the central id round-trips"); + + // An ATT request carries its request id and write flag back to the responder. + CBATTRequest *readReq = + simble_shadow_att_request(42, NO, mutableChar, c1, 0, NULL, 0); + uint64_t reqId = 0; + BOOL isWrite = YES; + CHECK(simble_shadow_request_id(readReq, &reqId, &isWrite) && reqId == 42 && isWrite == NO, + "a read request carries its id and a clear write flag"); + const uint8_t writeVal[] = {0x07}; + CBATTRequest *writeReq = + simble_shadow_att_request(43, YES, mutableChar, c1, 2, writeVal, sizeof(writeVal)); + CHECK(simble_shadow_request_id(writeReq, &reqId, &isWrite) && reqId == 43 && isWrite == YES, + "a write request carries its id and a set write flag"); + CHECK(writeReq.value.length == sizeof(writeVal) && + ((const uint8_t *)writeReq.value.bytes)[0] == 0x07, + "the write request carries the write value"); + + // Fail closed: a request the registry never minted does not resolve. + CHECK(!simble_shadow_request_id((CBATTRequest *)manager, &reqId, &isWrite), + "a non-minted object is not a managed request"); + simble_shadow_reset(); CHECK(!simble_shadow_is_managed_manager(manager), "reset drops every registration"); + CHECK(!simble_shadow_is_managed_peripheral_manager(pManager), + "reset drops the peripheral manager registration"); } printf(fails ? "SHADOW REGISTRY: %d failure(s)\n" : "SHADOW REGISTRY: ok\n", fails);