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()) }; + } +}