From b7166ae2209b29d728c696ade5b5583378fb8fa7 Mon Sep 17 00:00:00 2001 From: Afrijal Dzuhri Date: Thu, 17 Sep 2026 23:50:44 +0700 Subject: [PATCH 1/2] fix(macos): bundle sqlite so the CLI/GUI start on macOS 15 The system sqlite on macOS 15 is 3.43, which lacks the two-argument json_valid() used by the quick_preset migration, so the storage layer fails to open with "malformed database schema". Windows and Android already build with the bundled sqlite for the same reason. --- lib/Cargo.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 54241e3a..3bfd81dd 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -69,6 +69,8 @@ rusqlite = { workspace = true, features = ["bundled"] } rust-embed = { workspace = true, features = ["debug-embed"] } [target.'cfg(target_os = "macos")'.dependencies] +# The system sqlite on macOS 15 (3.43) is too old for the migrations (json_valid with 2 args needs 3.45+) +rusqlite = { workspace = true, features = ["bundled"] } tokio = { workspace = true, features = ["rt-multi-thread"] } objc2 = { workspace = true } objc2-foundation = { workspace = true } From 48c00a2fc0a6a47bea46b32030a0ca89073ea7f8 Mon Sep 17 00:00:00 2001 From: Afrijal Dzuhri Date: Thu, 17 Sep 2026 23:50:44 +0700 Subject: [PATCH 2/2] fix(macos): wait for rfcommChannelOpenComplete and only cycle the baseband connection as a fallback On macOS 15 (Apple Silicon, Soundcore R50i) every other connect failed with kIOReturnNotOpen after exhausting the write retries. closeConnection is asynchronous, so calling openConnection/openRFCOMMChannelAsync right after it (which ensure_connected did whenever the device was already connected) raced the teardown and the channel never finished opening. When that failed the device was left disconnected, so the next attempt took the plain openConnection path and succeeded, hence the alternation. - Reuse an existing baseband connection instead of cycling it on every connect. This also removes the audible disconnect/reconnect and cuts connect time from ~3.5s to ~0.5s when the device is already connected. - Wait for rfcommChannelOpenComplete (with a timeout) before reporting the channel as open, rather than finding out on the first write. Only the callback is trusted: isOpen() flips to true a few hundred ms earlier and writes in that window fail. - Keep the baseband cycle as a fallback for when the open times out on a pre-existing connection (the stuck-channel case), and wait for isConnected() to go false before reopening so the cycle actually works. Tested on macOS 15.4 / M-series / Soundcore R50i: 15/15 back-to-back connects + EQ writes (4 of them recovered via the fallback), 10/10 with a 1s gap, EQ presets and button config verified to persist from a fresh process. --- lib/src/connection_backend/macos/rfcomm.rs | 130 +++++++++++++++++---- 1 file changed, 110 insertions(+), 20 deletions(-) diff --git a/lib/src/connection_backend/macos/rfcomm.rs b/lib/src/connection_backend/macos/rfcomm.rs index ff8b79f1..e91fe263 100644 --- a/lib/src/connection_backend/macos/rfcomm.rs +++ b/lib/src/connection_backend/macos/rfcomm.rs @@ -41,6 +41,16 @@ const WRITE_NOT_OPEN_RETRY_DELAY: std::time::Duration = std::time::Duration::fro const SDP_QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +// How long to wait for rfcommChannelOpenComplete after openRFCOMMChannelAsync returns success. +// Observed at ~0.5-1s on macOS 15 when it succeeds; when the channel is stuck it never arrives. +const RFCOMM_OPEN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +// closeConnection is asynchronous: isConnected() stays true for a while afterwards (~0.5s +// observed on macOS 15), and calling openConnection/openRFCOMMChannelAsync during that window +// leaves the RFCOMM channel permanently "not open". Bounds how long to wait for it to settle. +const BASEBAND_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +const BASEBAND_CLOSE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50); + // CFRunLoopRun() returns immediately if nothing is scheduled at the moment it's called, so the // connection's event loop re-enters it in short bursts instead of once indefinitely. const RFCOMM_EVENT_POLL_SECONDS: CFTimeInterval = 0.1; @@ -90,15 +100,32 @@ impl RfcommBackend for MacosRfcommBackend { debug!("finding device with desired mac address"); let device = device_with_mac_address(mac_address)?; - debug!("ensuring baseband connection"); - ensure_connected(&device)?; + let was_connected = unsafe { device.isConnected() }; + if !was_connected { + debug!("opening baseband connection"); + open_baseband_connection(&device)?; + } debug!("selecting RFCOMM service"); let channel_id = select_channel_id(&device, &service_selection_strategy)?; debug!("opening RFCOMM channel"); - let connection = MacosRfcommConnection::open(device, channel_id)?; - Ok(Arc::new(connection)) + match MacosRfcommConnection::open(device.clone(), channel_id) { + Ok(connection) => Ok(Arc::new(connection)), + // A pre-existing baseband connection (auto-reconnected for audio, or left + // over from a previous session) can leave the RFCOMM channel stuck in "not + // open" forever. Cycling the baseband connection is the only known + // workaround, but it audibly disconnects/reconnects the device (e.g. an + // audio chime) and costs a few seconds, so it's only done once the fast + // path has demonstrably failed rather than on every connect. + Err(connection::Error::TimedOut { .. }) if was_connected => { + debug!("RFCOMM channel did not open, cycling baseband connection"); + cycle_baseband_connection(&device)?; + debug!("retrying RFCOMM channel open"); + Ok(Arc::new(MacosRfcommConnection::open(device, channel_id)?)) + } + Err(err) => Err(err), + } }, ) .await @@ -149,20 +176,27 @@ fn device_with_mac_address( ) } -// A device that's already connected (e.g. auto-reconnected for audio, or from a previous -// session) can leave openRFCOMMChannelAsync stuck in "not open" forever; the only known -// workaround is closing the existing connection first, so this always cycles it rather than -// no-opping when already connected. Audibly disconnects/reconnects the device (e.g. an audio -// chime) on every connect. -fn ensure_connected(device: &IOBluetoothDevice) -> connection::Result<()> { - if unsafe { device.isConnected() } { - debug!("device already connected, closing first to avoid a stuck RFCOMM channel"); - let close_status = unsafe { device.closeConnection() }; - if close_status != 0 { - debug!("closeConnection returned status {close_status}, opening anyway"); +fn cycle_baseband_connection(device: &IOBluetoothDevice) -> connection::Result<()> { + let close_status = unsafe { device.closeConnection() }; + if close_status != 0 { + debug!("closeConnection returned status {close_status}, opening anyway"); + } + let started_at = std::time::Instant::now(); + while unsafe { device.isConnected() } { + if started_at.elapsed() >= BASEBAND_CLOSE_TIMEOUT { + debug!("baseband connection still open after close timeout, opening anyway"); + break; } + thread::sleep(BASEBAND_CLOSE_POLL_INTERVAL); } - debug!("opening baseband connection"); + debug!( + "baseband connection closed after {:?}", + started_at.elapsed() + ); + open_baseband_connection(device) +} + +fn open_baseband_connection(device: &IOBluetoothDevice) -> connection::Result<()> { let status = unsafe { device.openConnection() }; if status != 0 { return Err(connection::Error::DeviceNotFound { @@ -359,6 +393,7 @@ fn uuid_from_sdp_uuid(sdp_uuid: &IOBluetoothSDPUUID) -> Option { } struct ConnectionState { + open_complete_sender: std::sync::mpsc::Sender, data_sender: mpsc::Sender>, status_sender: watch::Sender, running: Arc, @@ -379,6 +414,7 @@ define_class!( status: c_int, ) { debug!("rfcommChannelOpenComplete status: {status}"); + let _ = self.ivars().open_complete_sender.send(status); } #[unsafe(method(rfcommChannelData:data:length:))] @@ -420,11 +456,13 @@ define_class!( impl RfcommDelegate { fn new( + open_complete_sender: std::sync::mpsc::Sender, data_sender: mpsc::Sender>, status_sender: watch::Sender, running: Arc, ) -> Retained { let this = Self::alloc().set_ivars(ConnectionState { + open_complete_sender, data_sender, running, status_sender, @@ -488,6 +526,40 @@ impl MacosRfcommConnection { } } +// Only the callback counts: isOpen() flips to true a few hundred ms before +// rfcommChannelOpenComplete fires, and writes in that window fail (observed on macOS 15). +fn wait_for_open_complete( + open_complete_receiver: &std::sync::mpsc::Receiver, +) -> connection::Result<()> { + let started_at = std::time::Instant::now(); + loop { + match open_complete_receiver.try_recv() { + Ok(0) => return Ok(()), + Ok(status) => { + debug!("RFCOMM channel failed to open with status {status}"); + return Err(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }); + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + return Err(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }); + } + } + if started_at.elapsed() >= RFCOMM_OPEN_TIMEOUT { + debug!("timed out waiting for rfcommChannelOpenComplete"); + return Err(connection::Error::TimedOut { + action: "open RFCOMM channel", + }); + } + pump_run_loop(); + } +} + fn pump_run_loop() { unsafe { CFRunLoop::run_in_mode(kCFRunLoopDefaultMode, RFCOMM_EVENT_POLL_SECONDS, false); @@ -552,7 +624,13 @@ fn run_connection_thread( let span = debug_span!("MacosRfcommConnection connection thread"); let _span_guard = span.enter(); - let delegate = RfcommDelegate::new(data_sender, status_sender, running.clone()); + let (open_complete_sender, open_complete_receiver) = std::sync::mpsc::channel(); + let delegate = RfcommDelegate::new( + open_complete_sender, + data_sender, + status_sender, + running.clone(), + ); let mut channel: Option> = None; let delegate_object: &AnyObject = &delegate; @@ -564,9 +642,6 @@ fn run_connection_thread( ) }; - // openRFCOMMChannelAsync's return here (not the later rfcommChannelOpenComplete callback, - // which is unreliable and only useful for logging) is the actual success signal: the channel - // object is already valid and retained once this returns kIOReturnSuccess. let Some(channel) = channel.filter(|_| start_status == 0) else { debug!("openRFCOMMChannelAsync failed to start with status {start_status}"); let _ = open_result_sender.send(Err(connection::Error::DeviceNotFound { @@ -576,6 +651,21 @@ fn run_connection_thread( return; }; + // openRFCOMMChannelAsync returning kIOReturnSuccess only means the open was started. The + // channel isn't usable until rfcommChannelOpenComplete fires, and if it never does (see + // cycle_baseband_connection) writes fail with kIOReturnNotOpen forever, so wait for it here + // rather than discovering that on the first write. The callback may be delivered on this + // thread's run loop or the main thread's depending on macOS version, so pump ours while + // waiting and let the channel carry the result either way. + if let Err(err) = wait_for_open_complete(&open_complete_receiver) { + unsafe { + channel.setDelegate(None); + channel.closeChannel(); + } + let _ = open_result_sender.send(Err(err)); + return; + } + let _ = open_result_sender.send(Ok(())); // Empirically, channel callbacks (rfcommChannelData etc.) are delivered on whichever thread