Skip to content

feat(lib): add macOS connection backend using IOBluetooth - #374

Closed
afrijaldz wants to merge 1 commit into
Oppzippy:masterfrom
afrijaldz:feat/macos-backend
Closed

afrijaldz wants to merge 1 commit into
Oppzippy:masterfrom
afrijaldz:feat/macos-backend

Conversation

@afrijaldz

Copy link
Copy Markdown

Summary

Adds a macOS RFCOMM connection backend built on Apple's IOBluetooth framework, so both the CLI and GUI work on macOS. Currently macOS falls through to the none backend, and the docs/build-macos.md link in the README is dead.

  • New lib/src/connection_backend/macos/ with a small Objective-C shim (rfcomm.m, compiled by lib/build.rs via the cc crate) and a Rust RfcommBackend/RfcommConnection implementation over a C FFI.
  • openscq30_lib::run_with_main_run_loop() for macOS, used by the CLI (see below).
  • rusqlite with bundled on macOS, matching Windows/Android — the system sqlite is too old for the migrations (json_valid() arity error).
  • docs/build-macos.md.

IOBluetooth behaviour worth knowing

Some things that were not obvious and 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 that has no run loop, so the shim uses openRFCOMMChannelAsync and waits for rfcommChannelOpenComplete:status: on a semaphore.
  • Consequently the process must keep the main run loop running. The GUI already does via winit. The CLI now runs the tokio runtime on a secondary thread while the main thread spins the run loop (run_with_main_run_loop). This is macOS only; other platforms are unchanged.
  • IOBluetooth flattens single-element SDP sequences, so ServiceClassIDList can be a bare UUID data element rather than an array.
  • Callbacks into Rust happen on the main thread, so inbound packets use try_send rather than blocking. @synchronized guards the callback context against the channel being closed concurrently.

Testing

Tested on macOS 15 (Apple Silicon) with a Soundcore R50i (A3949):

  • paired-devices add / device list-settings — all A3949 settings listed
  • setting -g serialNumber -g firmwareVersionLeft -g batteryLevelLeft -g batteryLevelRight — correct values
  • setting -s presetEqualizerProfile=BassBooster -g volumeAdjustments — EQ bands change accordingly, and back to SoundcoreSignature
  • GUI builds and launches

cargo fmt and cargo clippy are clean for the new code. I only have one device to test with, so testing on other models/macOS versions would be welcome.

No CI or release packaging for macOS is included in this PR; happy to follow up on that separately if you'd like.

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.
@SeanathanVT

Copy link
Copy Markdown
Contributor

Duplicates some functionality from #372.

@afrijaldz

Copy link
Copy Markdown
Author

You're right, #372 covers the same ground (and more) and was opened first — I hadn't seen it when I started this. I'm fine closing this one in favour of #372 once it's on track to merge. I'll leave it open for now only as a reference for a couple of behavioural differences I hit on macOS 15 / Apple Silicon, which I've noted over on #372.

@afrijaldz afrijaldz mentioned this pull request Sep 17, 2026
SeanathanVT added a commit to SeanathanVT/OpenSCQ30 that referenced this pull request Sep 17, 2026
writeSync:length: must not be called with more bytes than the channel's
negotiated MTU, per IOBluetoothRFCOMMChannel's own documented contract.
Our do_write sent the whole buffer in one call with no MTU check at all.
Credit to afrijaldz's competing macOS PR (Oppzippy#374) for flagging this; splits
do_write into an MTU-chunking outer loop plus a small per-chunk
write_chunk helper carrying the existing not-open-yet retry logic
unchanged.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
SeanathanVT added a commit to SeanathanVT/OpenSCQ30 that referenced this pull request Sep 17, 2026
writeSync:length: must not be called with more bytes than the channel's
negotiated MTU, per IOBluetoothRFCOMMChannel's own documented contract.
Our do_write sent the whole buffer in one call with no MTU check at all.
Credit to afrijaldz's competing macOS PR (Oppzippy#374) for flagging this; splits
do_write into an MTU-chunking outer loop plus a small per-chunk
write_chunk helper carrying the existing not-open-yet retry logic
unchanged.

Co-Authored-By: Afrijal Dzuhri <[email protected]>
Co-Authored-By: Claude Sonnet 5 <[email protected]>
@Oppzippy

Copy link
Copy Markdown
Owner

Closing in favor of #372

@Oppzippy Oppzippy closed this Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants