From c2a5c0861b0c9a2506b5fb49818f83da25a3b28b Mon Sep 17 00:00:00 2001 From: Afrijal Dzuhri Date: Wed, 16 Sep 2026 17:02:50 +0700 Subject: [PATCH] feat(lib): add macOS connection backend using IOBluetooth Adds an RFCOMM backend for macOS so the CLI and GUI work there. A small Objective-C shim (compiled via the cc crate in build.rs) wraps IOBluetooth: listing connected paired devices, resolving SDP service UUIDs to RFCOMM channel ids, and opening/reading/writing channels. Notes on IOBluetooth behaviour that shaped the design: - On current macOS versions, IOBluetooth is backed by CoreBluetooth and delivers every RFCOMM delegate event on the main thread's run loop, regardless of which thread opened the channel. openRFCOMMChannelSync fails when called from a thread without a run loop, so the shim uses openRFCOMMChannelAsync and waits for rfcommChannelOpenComplete:status:. - The CLI therefore runs its tokio runtime on a secondary thread while the main thread spins a run loop (run_with_main_run_loop). The GUI already has one via winit. - IOBluetooth flattens single element SDP sequences, so ServiceClassIDList may be a bare UUID rather than an array. - The system sqlite on macOS is too old for the migrations, so rusqlite is built with the bundled feature like on Windows and Android. Tested with a Soundcore R50i (A3949): listing settings, reading battery and firmware info, and changing the equalizer preset. --- Cargo.lock | 1 + cli/src/main.rs | 22 +- docs/build-macos.md | 29 ++ lib/Cargo.toml | 8 + lib/build.rs | 12 + lib/src/connection_backend.rs | 7 + lib/src/connection_backend/macos.rs | 43 +++ lib/src/connection_backend/macos/rfcomm.m | 280 ++++++++++++++++++++ lib/src/connection_backend/macos/rfcomm.rs | 294 +++++++++++++++++++++ 9 files changed, 694 insertions(+), 2 deletions(-) create mode 100644 docs/build-macos.md create mode 100644 lib/src/connection_backend/macos.rs create mode 100644 lib/src/connection_backend/macos/rfcomm.m create mode 100644 lib/src/connection_backend/macos/rfcomm.rs diff --git a/Cargo.lock b/Cargo.lock index 264f578aa..fac162106 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4147,6 +4147,7 @@ dependencies = [ "async-trait", "bitflags 2.13.1", "bluer", + "cc", "futures", "hex", "i18n-embed", diff --git a/cli/src/main.rs b/cli/src/main.rs index aa860593b..ef1202b5b 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -11,8 +11,7 @@ use openscq30_lib::OpenSCQ30Session; use tracing::level_filters::LevelFilter; use tracing_subscriber::EnvFilter; -#[tokio::main] -async fn main() -> ExitCode { +fn main() -> ExitCode { let matches = cli::build().get_matches(); if let Err(err) = initialize_logging(&matches) { @@ -20,6 +19,25 @@ async fn main() -> ExitCode { return ExitCode::FAILURE; } + let run = move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build tokio runtime") + .block_on(run(matches)) + }; + // IOBluetooth needs the main thread to be running a run loop, so we have to work in another thread + #[cfg(target_os = "macos")] + { + openscq30_lib::run_with_main_run_loop(run) + } + #[cfg(not(target_os = "macos"))] + { + run() + } +} + +async fn run(matches: ArgMatches) -> ExitCode { if let Err(err) = cli::handle(&matches).await { if matches.get_count("verbose") > 0 || matches.get_flag("debug-errors") { eprintln!("Error: {err:?}"); diff --git a/docs/build-macos.md b/docs/build-macos.md new file mode 100644 index 000000000..79abaea3b --- /dev/null +++ b/docs/build-macos.md @@ -0,0 +1,29 @@ +# Building on macOS + +## Requirements + +- Xcode Command Line Tools (`xcode-select --install`) for clang and the IOBluetooth framework +- Rust (see `.build-tool-versions` for the version used in CI) + +## Building + +```sh +cargo build --release -p openscq30-cli +cargo build --release -p openscq30-gui +``` + +The binaries will be at `target/release/openscq30` and `target/release/openscq30-gui`. + +## How the macOS backend works + +Bluetooth connections go through Apple's IOBluetooth framework, using a small Objective-C shim in +`lib/src/connection_backend/macos/rfcomm.m` that is compiled by `lib/build.rs`. + +On current macOS versions, IOBluetooth delivers RFCOMM events (open complete, incoming data, +disconnect) on the main thread's run loop, no matter which thread opened the channel. The GUI +already has a main run loop, but the CLI does not, so on macOS the CLI runs its logic on a +secondary thread while the main thread spins a run loop +(`openscq30_lib::run_with_main_run_loop`). Anything else embedding `openscq30-lib` on macOS +needs to do the same. + +The device must already be paired and connected in System Settings > Bluetooth. diff --git a/lib/Cargo.toml b/lib/Cargo.toml index f4fba3202..a001d860b 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -64,6 +64,14 @@ windows = { workspace = true, features = [ "Networking_Sockets", ] } +[target.'cfg(target_os = "macos")'.dependencies] +# macOS ships an old sqlite that lacks features used by the migrations +rusqlite = { workspace = true, features = ["bundled"] } +tokio = { workspace = true, features = ["rt-multi-thread"] } + +[target.'cfg(target_os = "macos")'.build-dependencies] +cc = "1" + [target.'cfg(target_os = "android")'.dependencies] rusqlite = { workspace = true, features = ["bundled"] } rust-embed = { workspace = true, features = ["debug-embed"] } diff --git a/lib/build.rs b/lib/build.rs index 34323449b..faf2da8b8 100644 --- a/lib/build.rs +++ b/lib/build.rs @@ -1,3 +1,15 @@ fn main() { println!("cargo:rerun-if-changed=i18n"); + + #[cfg(target_os = "macos")] + { + println!("cargo:rerun-if-changed=src/connection_backend/macos/rfcomm.m"); + cc::Build::new() + .file("src/connection_backend/macos/rfcomm.m") + .flag("-fobjc-arc") + .flag("-fmodules") + .compile("openscq30_iobluetooth"); + println!("cargo:rustc-link-lib=framework=Foundation"); + println!("cargo:rustc-link-lib=framework=IOBluetooth"); + } } diff --git a/lib/src/connection_backend.rs b/lib/src/connection_backend.rs index 94b562c06..3d15f8d84 100644 --- a/lib/src/connection_backend.rs +++ b/lib/src/connection_backend.rs @@ -8,6 +8,10 @@ cfg_select! { target_os = "windows" => { mod windows; } + target_os = "macos" => { + mod macos; + pub use macos::run_with_main_run_loop; + } _ => { mod none; } @@ -30,6 +34,9 @@ pub fn default_backends() -> Option { target_os = "windows" => { Some(windows::PlatformConnectionBackends) } + target_os = "macos" => { + Some(macos::PlatformConnectionBackends) + } _ => { None:: } diff --git a/lib/src/connection_backend/macos.rs b/lib/src/connection_backend/macos.rs new file mode 100644 index 000000000..12e50fc07 --- /dev/null +++ b/lib/src/connection_backend/macos.rs @@ -0,0 +1,43 @@ +mod rfcomm; + +use std::thread; + +use crate::{api::connection, connection_backend::ConnectionBackends}; + +#[derive(Default)] +pub struct PlatformConnectionBackends; + +impl ConnectionBackends for PlatformConnectionBackends { + type Rfcomm = rfcomm::IOBluetoothRfcommBackend; + + async fn rfcomm(&self) -> connection::Result { + Ok(rfcomm::IOBluetoothRfcommBackend) + } +} + +/// IOBluetooth delivers RFCOMM events through the main thread's run loop, so applications without +/// one of their own (such as the CLI) must run their logic on another thread while the main thread +/// spins the run loop. This runs `f` on a new thread, keeps the main run loop going until `f` +/// returns, then returns `f`'s result. Must be called from the main thread. +pub fn run_with_main_run_loop(f: impl FnOnce() -> T + Send + 'static) -> T { + /// Stops the main run loop when dropped, so that a panic in `f` doesn't leave the main thread + /// spinning forever. + struct StopMainRunLoopOnDrop; + impl Drop for StopMainRunLoopOnDrop { + fn drop(&mut self) { + unsafe { rfcomm::ffi::scq_main_run_loop_stop() }; + } + } + + let handle = thread::spawn(move || { + let _guard = StopMainRunLoopOnDrop; + // The run loop must keep going until everything inside f has been dropped (closing + // connections), which is why the guard is dropped after f returns. + f() + }); + unsafe { rfcomm::ffi::scq_main_run_loop_run() }; + match handle.join() { + Ok(result) => result, + Err(panic) => std::panic::resume_unwind(panic), + } +} diff --git a/lib/src/connection_backend/macos/rfcomm.m b/lib/src/connection_backend/macos/rfcomm.m new file mode 100644 index 000000000..3dedbf03c --- /dev/null +++ b/lib/src/connection_backend/macos/rfcomm.m @@ -0,0 +1,280 @@ +// Thin C-callable wrapper around IOBluetooth's RFCOMM API. +// +// On current macOS versions IOBluetooth is backed by CoreBluetooth, and channel events (open +// complete, incoming data, close) are delivered on the main thread's run loop regardless of which +// thread opened the channel. Opening a channel blocks until that event arrives, so the process must +// keep its main run loop running (see scq_main_run_loop_run). The functions here may be called from +// any thread except the main thread (they block waiting for main thread events). Callbacks into +// Rust are invoked on the main thread and must not block. + +#import +#import + +#include +#include + +typedef void (*scq_device_cb)(void *ctx, const char *name, const uint8_t mac[6]); +typedef void (*scq_service_cb)(void *ctx, const uint8_t uuid[16], uint8_t channel_id); +typedef void (*scq_data_cb)(void *ctx, const uint8_t *data, size_t len); +typedef void (*scq_closed_cb)(void *ctx); + +#define SCQ_OK 0 +#define SCQ_ERR_DEVICE_NOT_FOUND 1 +#define SCQ_ERR_IO 3 + +// --------------------------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------------------------- + +static IOBluetoothDevice *scq_find_device(const uint8_t mac[6]) { + BluetoothDeviceAddress address; + memcpy(address.data, mac, 6); + return [IOBluetoothDevice deviceWithAddress:&address]; +} + +// Extracts all 128-bit UUIDs from the ServiceClassIDList attribute of an SDP record. +static NSArray *scq_record_uuids(IOBluetoothSDPServiceRecord *record) { + NSMutableArray *uuids = [NSMutableArray array]; + IOBluetoothSDPDataElement *classIdList = + [record getAttributeDataElement:kBluetoothSDPAttributeIdentifierServiceClassIDList]; + if (classIdList == nil) { + return uuids; + } + // IOBluetooth flattens single element sequences, so this is either a sequence of UUIDs or a + // bare UUID. + NSArray *elements = [classIdList getTypeDescriptor] == kBluetoothSDPDataElementTypeUUID + ? @[ classIdList ] + : [classIdList getArrayValue]; + for (IOBluetoothSDPDataElement *element in elements) { + IOBluetoothSDPUUID *uuid = [element getUUIDValue]; + if (uuid == nil) { + continue; + } + IOBluetoothSDPUUID *uuid128 = [uuid getUUIDWithLength:16]; + if (uuid128 != nil && uuid128.length == 16) { + [uuids addObject:[NSData dataWithBytes:uuid128.bytes length:16]]; + } + } + return uuids; +} + +// --------------------------------------------------------------------------------------------- +// SDP query (used when the cached service list is empty) +// --------------------------------------------------------------------------------------------- + +@interface SCQSDPQueryTarget : NSObject +@property(nonatomic, strong) dispatch_semaphore_t semaphore; +@property(nonatomic, assign) IOReturn status; +@end + +@implementation SCQSDPQueryTarget +- (void)sdpQueryComplete:(IOBluetoothDevice *)device status:(IOReturn)status { + self.status = status; + dispatch_semaphore_signal(self.semaphore); +} +@end + +static void scq_refresh_services(IOBluetoothDevice *device) { + SCQSDPQueryTarget *target = [[SCQSDPQueryTarget alloc] init]; + target.semaphore = dispatch_semaphore_create(0); + if ([NSThread isMainThread]) { + // The completion is delivered on the main thread, so we can't wait for it here + return; + } + if ([device performSDPQuery:target] == kIOReturnSuccess) { + dispatch_semaphore_wait(target.semaphore, + dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)); + } +} + +// --------------------------------------------------------------------------------------------- +// Channel +// --------------------------------------------------------------------------------------------- + +@interface SCQChannel : NSObject +@property(nonatomic, strong) IOBluetoothRFCOMMChannel *channel; +@property(nonatomic, strong) IOBluetoothDevice *device; +@property(nonatomic, assign) scq_data_cb dataCallback; +@property(nonatomic, assign) scq_closed_cb closedCallback; +@property(nonatomic, assign) void *context; +@property(nonatomic, assign) BOOL closed; +@property(nonatomic, strong) dispatch_semaphore_t openSemaphore; +@property(nonatomic, assign) IOReturn openStatus; +@end + +@implementation SCQChannel + +- (void)rfcommChannelOpenComplete:(IOBluetoothRFCOMMChannel *)rfcommChannel status:(IOReturn)error { + self.openStatus = error; + dispatch_semaphore_signal(self.openSemaphore); +} + +// `closed` is guarded by @synchronized(self) so that once scq_rfcomm_close returns, no callback +// can be running or start running with a dangling context. + +- (void)rfcommChannelData:(IOBluetoothRFCOMMChannel *)rfcommChannel + data:(void *)dataPointer + length:(size_t)dataLength { + @synchronized(self) { + if (!self.closed) { + self.dataCallback(self.context, (const uint8_t *)dataPointer, dataLength); + } + } +} + +- (void)rfcommChannelClosed:(IOBluetoothRFCOMMChannel *)rfcommChannel { + @synchronized(self) { + if (!self.closed) { + self.closed = YES; + self.closedCallback(self.context); + } + } +} + +- (void)close { + @synchronized(self) { + self.closed = YES; + [self.channel setDelegate:nil]; + [self.channel closeChannel]; + self.channel = nil; + } +} + +@end + +// --------------------------------------------------------------------------------------------- +// Public C API +// --------------------------------------------------------------------------------------------- + +// Lists paired devices that are currently connected. +void scq_bt_connected_devices(scq_device_cb callback, void *ctx) { + @autoreleasepool { + NSArray *devices = [IOBluetoothDevice pairedDevices]; + for (IOBluetoothDevice *device in devices) { + if (![device isConnected]) { + continue; + } + const BluetoothDeviceAddress *address = [device getAddress]; + if (address == NULL) { + continue; + } + NSString *name = [device name]; + const char *cName = name != nil ? [name UTF8String] : ""; + callback(ctx, cName, address->data); + } + } +} + +// Lists the RFCOMM services advertised by the device as (uuid, channel id) pairs. A service with +// multiple UUIDs in its class id list is reported once per UUID. +int scq_bt_rfcomm_services(const uint8_t mac[6], scq_service_cb callback, void *ctx) { + @autoreleasepool { + IOBluetoothDevice *device = scq_find_device(mac); + if (device == nil) { + return SCQ_ERR_DEVICE_NOT_FOUND; + } + NSArray *services = [device services]; + if (services == nil || services.count == 0) { + scq_refresh_services(device); + services = [device services]; + } + for (IOBluetoothSDPServiceRecord *record in services) { + BluetoothRFCOMMChannelID channelId = 0; + if ([record getRFCOMMChannelID:&channelId] != kIOReturnSuccess) { + continue; + } + for (NSData *uuid in scq_record_uuids(record)) { + callback(ctx, (const uint8_t *)uuid.bytes, channelId); + } + } + return SCQ_OK; + } +} + +// Opens an RFCOMM channel. Returns an opaque handle, or NULL on failure with `error_out` set. +void *scq_rfcomm_open(const uint8_t mac[6], + uint8_t channel_id, + scq_data_cb data_callback, + scq_closed_cb closed_callback, + void *ctx, + int *error_out) { + @autoreleasepool { + IOBluetoothDevice *device = scq_find_device(mac); + if (device == nil) { + *error_out = SCQ_ERR_DEVICE_NOT_FOUND; + return NULL; + } + SCQChannel *wrapper = [[SCQChannel alloc] init]; + wrapper.device = device; + wrapper.dataCallback = data_callback; + wrapper.closedCallback = closed_callback; + wrapper.context = ctx; + + // The sync variant needs a run loop on the calling thread, so open asynchronously and wait + // for the completion event, which arrives on the main thread. + wrapper.openSemaphore = dispatch_semaphore_create(0); + wrapper.openStatus = kIOReturnError; + IOBluetoothRFCOMMChannel *channel = nil; + IOReturn result = [device openRFCOMMChannelAsync:&channel + withChannelID:channel_id + delegate:wrapper]; + if (result != kIOReturnSuccess || channel == nil) { + *error_out = SCQ_ERR_IO; + return NULL; + } + wrapper.channel = channel; + if (dispatch_semaphore_wait(wrapper.openSemaphore, + dispatch_time(DISPATCH_TIME_NOW, 15 * NSEC_PER_SEC)) != 0 || + wrapper.openStatus != kIOReturnSuccess) { + [wrapper close]; + *error_out = SCQ_ERR_IO; + return NULL; + } + *error_out = SCQ_OK; + return (void *)CFBridgingRetain(wrapper); + } +} + +// Writes `len` bytes as a single RFCOMM frame where possible (splitting at the channel MTU). +int scq_rfcomm_write(void *handle, const uint8_t *data, size_t len) { + @autoreleasepool { + SCQChannel *wrapper = (__bridge SCQChannel *)handle; + IOBluetoothRFCOMMChannel *channel = wrapper.channel; + if (wrapper.closed || channel == nil) { + return SCQ_ERR_IO; + } + BluetoothRFCOMMMTU mtu = [channel getMTU]; + IOReturn result = kIOReturnSuccess; + size_t offset = 0; + while (offset < len && result == kIOReturnSuccess) { + size_t chunk = len - offset; + if (mtu > 0 && chunk > mtu) { + chunk = mtu; + } + result = [channel writeSync:(void *)(data + offset) length:(UInt16)chunk]; + offset += chunk; + } + return result == kIOReturnSuccess ? SCQ_OK : SCQ_ERR_IO; + } +} + +// Closes the channel and releases the handle. The callbacks will not be invoked after this returns. +void scq_rfcomm_close(void *handle) { + @autoreleasepool { + SCQChannel *wrapper = (__bridge_transfer SCQChannel *)handle; + [wrapper close]; + } +} + +// Runs the main thread's run loop until scq_main_run_loop_stop is called. Must be called from the +// main thread. +void scq_main_run_loop_run(void) { + // CFRunLoopRun returns immediately if there are no input sources, so add a dummy one + [[NSRunLoop mainRunLoop] addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; + CFRunLoopRun(); +} + +// Stops a run loop started with scq_main_run_loop_run. May be called from any thread. +void scq_main_run_loop_stop(void) { + CFRunLoopStop(CFRunLoopGetMain()); +} diff --git a/lib/src/connection_backend/macos/rfcomm.rs b/lib/src/connection_backend/macos/rfcomm.rs new file mode 100644 index 000000000..491a5b0b0 --- /dev/null +++ b/lib/src/connection_backend/macos/rfcomm.rs @@ -0,0 +1,294 @@ +use std::{ + collections::{HashMap, HashSet}, + ffi::{CStr, c_char, c_int, c_void}, + panic::Location, + ptr::NonNull, + sync::{Arc, Mutex}, +}; + +use async_trait::async_trait; +use macaddr::MacAddr6; +use tokio::sync::{mpsc, watch}; +use tracing::{debug, debug_span, trace, warn}; +use uuid::Uuid; + +use crate::{ + api::connection::{self, ConnectionStatus, RfcommBackend, RfcommConnection}, + connection::RfcommServiceSelectionStrategy, +}; + +// See rfcomm.m for the implementation of these. +pub(super) mod ffi { + use std::ffi::{c_char, c_int, c_void}; + + pub const OK: c_int = 0; + pub const ERR_DEVICE_NOT_FOUND: c_int = 1; + + pub type DeviceCallback = + unsafe extern "C" fn(ctx: *mut c_void, name: *const c_char, mac: *const u8); + pub type ServiceCallback = + unsafe extern "C" fn(ctx: *mut c_void, uuid: *const u8, channel_id: u8); + pub type DataCallback = unsafe extern "C" fn(ctx: *mut c_void, data: *const u8, len: usize); + pub type ClosedCallback = unsafe extern "C" fn(ctx: *mut c_void); + + unsafe extern "C" { + pub fn scq_bt_connected_devices(callback: DeviceCallback, ctx: *mut c_void); + pub fn scq_bt_rfcomm_services( + mac: *const u8, + callback: ServiceCallback, + ctx: *mut c_void, + ) -> c_int; + pub fn scq_rfcomm_open( + mac: *const u8, + channel_id: u8, + data_callback: DataCallback, + closed_callback: ClosedCallback, + ctx: *mut c_void, + error_out: *mut c_int, + ) -> *mut c_void; + pub fn scq_rfcomm_write(handle: *mut c_void, data: *const u8, len: usize) -> c_int; + pub fn scq_rfcomm_close(handle: *mut c_void); + pub fn scq_main_run_loop_run(); + pub fn scq_main_run_loop_stop(); + } +} + +#[derive(thiserror::Error, Debug)] +#[error("IOBluetooth error code {0}")] +struct IOBluetoothError(c_int); + +#[derive(Default)] +pub struct IOBluetoothRfcommBackend; + +#[async_trait] +impl RfcommBackend for IOBluetoothRfcommBackend { + async fn devices(&self) -> connection::Result> { + tokio::task::spawn_blocking(|| { + let mut descriptors = HashSet::new(); + + unsafe extern "C" fn on_device(ctx: *mut c_void, name: *const c_char, mac: *const u8) { + // SAFETY: ctx is the &mut HashSet passed below, and the callback is only invoked + // synchronously for the duration of scq_bt_connected_devices. + let descriptors = + unsafe { &mut *(ctx as *mut HashSet) }; + let name = unsafe { CStr::from_ptr(name) } + .to_string_lossy() + .into_owned(); + let mac_address = unsafe { mac_from_ptr(mac) }; + descriptors.insert(connection::ConnectionDescriptor { name, mac_address }); + } + + unsafe { + ffi::scq_bt_connected_devices(on_device, &mut descriptors as *mut _ as *mut c_void); + } + Ok(descriptors) + }) + .await + .unwrap() + } + + async fn connect( + &self, + mac_address: MacAddr6, + service_selection_strategy: RfcommServiceSelectionStrategy, + ) -> connection::Result> { + tokio::task::spawn_blocking( + move || -> connection::Result> { + let span = debug_span!( + "RfcommBackend::connect", + mac_address = tracing::field::display(mac_address), + ); + let _span_guard = span.enter(); + + debug!("listing RFCOMM services"); + let services = Self::rfcomm_services(mac_address)?; + debug!("found RFCOMM services: {:?}", services.keys()); + + let uuid = match service_selection_strategy { + RfcommServiceSelectionStrategy::Constant(uuid) => uuid, + RfcommServiceSelectionStrategy::Dynamic(select_service) => { + select_service(services.keys().copied().collect()) + } + }; + debug!("using RFCOMM service: {uuid:?}"); + let channel_id = + services + .get(&uuid) + .copied() + .ok_or(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + })?; + + debug!("opening RFCOMM channel {channel_id}"); + let connection = IOBluetoothRfcommConnection::open(mac_address, channel_id)?; + Ok(Arc::new(connection)) + }, + ) + .await + .unwrap() + } +} + +impl IOBluetoothRfcommBackend { + /// Returns a map of service UUID -> RFCOMM channel id + fn rfcomm_services(mac_address: MacAddr6) -> connection::Result> { + let mut services = HashMap::new(); + + unsafe extern "C" fn on_service(ctx: *mut c_void, uuid: *const u8, channel_id: u8) { + // SAFETY: ctx is the &mut HashMap passed below, and the callback is only invoked + // synchronously for the duration of scq_bt_rfcomm_services. + let services = unsafe { &mut *(ctx as *mut HashMap) }; + let bytes: [u8; 16] = unsafe { std::slice::from_raw_parts(uuid, 16) } + .try_into() + .unwrap(); + services.insert(Uuid::from_bytes(bytes), channel_id); + } + + let result = unsafe { + ffi::scq_bt_rfcomm_services( + mac_address.as_bytes().as_ptr(), + on_service, + &mut services as *mut _ as *mut c_void, + ) + }; + match result { + ffi::OK => Ok(services), + ffi::ERR_DEVICE_NOT_FOUND => Err(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }), + code => Err(connection::Error::Other { + source: Box::new(IOBluetoothError(code)), + location: Location::caller(), + }), + } + } +} + +unsafe fn mac_from_ptr(mac: *const u8) -> MacAddr6 { + let bytes: [u8; 6] = unsafe { std::slice::from_raw_parts(mac, 6) } + .try_into() + .unwrap(); + MacAddr6::from(bytes) +} + +/// Shared between the connection and the IOBluetooth callbacks. Lives until the channel is closed, +/// at which point no further callbacks can occur. +struct CallbackContext { + packet_sender: mpsc::Sender>, + connection_status_sender: watch::Sender, +} + +pub struct IOBluetoothRfcommConnection { + handle: NonNull, + _context: Box, + read_channel: Mutex>>>, + connection_status_receiver: watch::Receiver, +} + +// SAFETY: the handle is only ever used through the scq_rfcomm_* functions, which are thread safe. +unsafe impl Send for IOBluetoothRfcommConnection {} +unsafe impl Sync for IOBluetoothRfcommConnection {} + +impl IOBluetoothRfcommConnection { + fn open(mac_address: MacAddr6, channel_id: u8) -> connection::Result { + let (packet_sender, packet_receiver) = mpsc::channel(100); + let (connection_status_sender, connection_status_receiver) = + watch::channel(ConnectionStatus::Connected); + let context = Box::new(CallbackContext { + packet_sender, + connection_status_sender, + }); + + unsafe extern "C" fn on_data(ctx: *mut c_void, data: *const u8, len: usize) { + let context = unsafe { &*(ctx as *const CallbackContext) }; + let packet = unsafe { std::slice::from_raw_parts(data, len) }.to_vec(); + trace!("received packet: {packet:?}"); + // We're on the IOBluetooth thread, so don't block it if the consumer is slow + if let Err(err) = context.packet_sender.try_send(packet) { + warn!("dropping inbound packet: {err:?}"); + } + } + + unsafe extern "C" fn on_closed(ctx: *mut c_void) { + let context = unsafe { &*(ctx as *const CallbackContext) }; + debug!("RFCOMM channel closed"); + context + .connection_status_sender + .send_replace(ConnectionStatus::Disconnected); + } + + let mut error_code: c_int = ffi::OK; + let handle = unsafe { + ffi::scq_rfcomm_open( + mac_address.as_bytes().as_ptr(), + channel_id, + on_data, + on_closed, + &*context as *const CallbackContext as *mut c_void, + &mut error_code, + ) + }; + let Some(handle) = NonNull::new(handle) else { + return Err(match error_code { + ffi::ERR_DEVICE_NOT_FOUND => connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }, + code => connection::Error::Other { + source: Box::new(IOBluetoothError(code)), + location: Location::caller(), + }, + }); + }; + + Ok(Self { + handle, + _context: context, + read_channel: Mutex::new(Some(packet_receiver)), + connection_status_receiver, + }) + } +} + +#[async_trait] +impl RfcommConnection for IOBluetoothRfcommConnection { + async fn write(&self, data: &[u8]) -> connection::Result<()> { + let handle = self.handle.as_ptr() as usize; + let data = data.to_owned(); + tokio::task::spawn_blocking(move || -> connection::Result<()> { + let result = + unsafe { ffi::scq_rfcomm_write(handle as *mut c_void, data.as_ptr(), data.len()) }; + if result != ffi::OK { + return Err(connection::Error::WriteError { + source: Some(Box::new(IOBluetoothError(result))), + location: Location::caller(), + }); + } + trace!("wrote packet: {data:?}"); + Ok(()) + }) + .await + .unwrap() + } + + fn read_channel(&self) -> mpsc::Receiver> { + self.read_channel + .lock() + .unwrap() + .take() + .expect("read_channel may only be called once per IOBluetoothRfcommConnection") + } + + fn connection_status(&self) -> watch::Receiver { + self.connection_status_receiver.clone() + } +} + +impl Drop for IOBluetoothRfcommConnection { + fn drop(&mut self) { + // Closing synchronously guarantees no callbacks reference `context` after this returns + unsafe { ffi::scq_rfcomm_close(self.handle.as_ptr()) }; + } +}