Repository navigation
Add macOS support - #372
SeanathanVT wants to merge 24 commits into
Conversation
- implement a macOS connection backend (lib/src/connection_backend/macos/rfcomm.rs)
using objc2-io-bluetooth/IOBluetooth.framework, implementing the same
RfcommBackend/RfcommConnection traits as the existing Windows/Linux backends
- ensure a baseband connection before service selection, query SDP on-demand
(bounded by a timeout) when the cached service list is empty, and fix
ServiceClassIDList parsing to handle the single-UUID case that
getArrayValue() never recognized
- use openRFCOMMChannelAsync and treat its synchronous return as the success
signal, since rfcommChannelOpenComplete is unreliable and never fires
- have a single thread own the channel's entire lifecycle (open, write,
run-loop pumping, close), and give the CLI's actual main thread a real run
loop to pump instead of letting tokio's #[tokio::main] block it -
IOBluetooth dispatches delegate callbacks through the process's main-thread
run loop regardless of which thread issues the calls, so without this,
RFCOMM reads never complete
- retry writeSync on transient kIOReturnNotOpen, since the channel can take
up to ~2s to finish opening after openRFCOMMChannelAsync returns
- skip individual paired devices whose MAC address can't be read instead of
failing the whole device list
- add debug/trace logging across the connection lifecycle and the shared
packet I/O handler
- justfile: remove the prior binary before copying the new one in the
build-cli/build-gui recipes, fixing a codesigning/page-cache staleness bug
from overwriting in place; add a build-gui-bundle recipe
- packaging/macos/{build.sh,Info.plist}: build the .app bundle, including the
Bluetooth usage description required for the permission prompt
- add macOS to CI (build.yml, test-macos.yml) and build docs
(docs/build-macos.md)
Co-Authored-By: Claude <[email protected]>
Co-Authored-By: opencode <[email protected]>
- parse battery level/charging, dual firmware version, and serial number from the device's state-update packet, verified against a real capture - preserve everything past those fields verbatim in an unknown_suffix rather than guessing at layout - register SoundcoreA3953 in device_model.rs/soundcore.rs following the existing a3947/a3954 pattern, plus the README/translation entries required for it to compile and show up as a selectable model - equalizer, sound modes, buttons, and other flags are not yet implemented - they need more captures with individual settings toggled via the official app to identify field offsets Co-Authored-By: Claude <[email protected]>
… (A3953) - add a device-specific SoundModes struct and wire it through the shared sound_modes_v2 module, exposing AmbientSoundMode as a real read/write setting (command 0x06/0x81) - derive the field's packet offset dynamically from a length byte read earlier in the packet, matching the official app's own offset math, rather than hardcoding the position observed in one capture - preserve button config, the ambient-sound-mode cycle bits, and every other still-unimplemented field verbatim for round-trip correctness; the cycle bits are parsed but not exposed since no outbound command for them was found - verify against three real captures of the same device (Normal, Noise Canceling, Transparency) Co-Authored-By: Claude <[email protected]>
battery, press sensitivity, and button config decoding for the A3953 - add WearingDetection, Ldac, AutoPowerOff, WearingTone, and CaseBatteryLevel settings, each reusing this project's existing shared structures/commands, cross-confirmed against the decompiled app rather than assumed from the sibling A3947/A3954 - confirm A3953's auto power off options (10m/20m/30m/60m) directly from a literal array in the app's own PowerOffModel, upgrading that assumption from "matches a sibling device" to "confirmed for this model by name" - add PressSensitivity as a new cross-device SettingId (0-4 integer), traced end-to-end from the app's UI layer through to its command bytes - decode the full 16-byte button configuration block into a typed ButtonConfig/ButtonAssignment structure (32 sub-fields: 8 press-types x switch/action x TWS/non-TWS), verified bit-for-bit against a real capture; not exposed as a setting since no set command or action-ID meaning was found - expose WindNoiseSuppression, already-parsed but previously unused - document every remaining unparsed byte range with its cause (no command found, EQ encoding conflicts with this project's existing convention, or not yet implemented) so the next pass has exact offsets to start from instead of starting over Co-Authored-By: Claude <[email protected]>
connection, and spatial audio settings for the A3953 - add LowBatteryPrompt (reusing this project's existing shared flag and command), AmbientSoundPrompt, SupportTwoConnections, and SpatialAudio (switch, effect mode, content mode), each traced end to end from the app's per-feature Activity/ViewModel classes through the manager layer to concrete command bytes - resolve the earlier custom-EQ byte-to-dB formula question: the app's own EqVM applies a +12 offset before the raw byte transform, which combined is identical to this project's existing VolumeAdjustments encoding, not a conflicting one - document why EQ write support and a few remaining fields (bass up, four health-tracking bytes, button action IDs) are still not implemented, now with a specific blocking reason for each rather than "not found" Co-Authored-By: Claude <[email protected]>
…s for the A3953 Reverse-engineered the equalizer write path (command [0x03, 0x87]) and the button-config action IDs from the official app's decompiled source, cross- validated against this project's own a3947/a3955 devices which share the same wire format. Button config stays read-only: no device family in the decompiled app has a command that writes it back. Co-Authored-By: Claude <[email protected]>
…nt bloat - perform_sdp_query's timeout was checked against CFRunLoop::run_in_mode on the wrong thread and never actually bounded the wait; switched to recv_timeout on the (thread-agnostic) result channel. - rfcommChannelData's blocking_send could freeze the whole connection (reads, writes, and run-loop pumping all share one thread) if the read buffer ever filled; switched to try_send, dropping on backpressure. - cli/main.rs's macOS run loop busy-spun at 100% CPU whenever nothing was yet scheduled on it; now sleeps between pumps when that happens. - A panic on macOS's worker thread gave exit code 1 instead of the 101 other platforms use for the same panic; now joins and matches it. - Replaced a comment asserting a causal theory this branch's own investigation already falsified with what's actually true. - Shrunk WRITE_DRAIN_TIMEOUT (100ms -> 10ms) to tighten the latency floor on inbound callbacks between writes. - a3953's AmbientSoundPrompt/SupportTwoConnections now go through the shared flag! plumbing instead of hand-rolled handlers/modifiers. - Dropped six always-discarded Vec<u8> fields from A3953StateUpdatePacket in favor of fixed-size padding at parse time, matching a3947's pattern (kept unknown_gap/unknown_suffix, which are genuinely variable-length). - Condensed a3953's doc-comment citations into single-line notes. Co-Authored-By: Claude Sonnet 5 <[email protected]>
…hared parser edge case A real capture showed the earbuds already push [0x0B, 0x01] device-list packets matching this project's existing common::modules::dual_connections wire format exactly (same request/response command, same enabled-toggle command A3953 already implemented). Rather than the single toggle bit that was there before, A3953 now uses that shared module directly, the same way a3955 already does. - A3953State's Update impl is now hand-written instead of relying on the blanket From-based one: a plain full replace would wipe the fetched device list on every subsequent state refresh, since the list isn't part of the main state packet. - Testing against the real capture bytes found a genuine bug in the shared parser: a lone trailing device entry (alone in the final packet of a multi-packet response) is padded a couple bytes short of what its own length byte claims. DualConnectionsDevice::take now clamps to whatever's actually available instead of erroring, fixing this for every device that uses dual_connections, not just the A3953. Co-Authored-By: Claude Sonnet 5 <[email protected]>
IOBluetoothDevice doesn't retain the target passed to performSDPQuery, and there's no way to cancel a query in flight. The recv_timeout fix from the previous commit made the 10s timeout actually fire for the first time; before that it was effectively unreachable since the wait blocked until the real callback. Now that it's reachable, dropping the delegate normally on timeout risked IOBluetooth calling back into deallocated memory if the query completed afterward. Leak it deliberately on the timeout path instead, bounded to at most once per timed-out query. Co-Authored-By: Claude Sonnet 5 <[email protected]>
…entions Audited the branch's deltas for cohesion with the rest of the Soundcore device implementations: - Drop SettingId::SupportTwoConnections and its FTL string, both orphaned once A3953 was rewired onto the shared dual_connections module. - Remove a3953.rs's 20-line top-of-file comment; it only duplicated citations already inline at their actual definition sites. - Match the private `mod structures;` visibility used by every sibling device (was `pub mod structures;`). - Replace three `///` doc comments with `//` or remove them where redundant, matching this project's no-doc-comment convention. - Drop a `let result = ...; result` binding in the macOS write() path to match how windows/linux end the same function; incidentally clears a pre-existing clippy let_and_return warning. Co-Authored-By: Claude Sonnet 5 <[email protected]>
…rt for the A3953 - Corrected two "no write command found" claims from earlier reverse- engineering: both commands are generic, un-overridden methods on the shared CmmBtCmdService base class, not per-device overrides, so grepping A3953CmdService alone for them found nothing. ambient-sound-mode-cycle now wires into the same shared builder method 10 other devices use. Button config is rewired from a hand-rolled read-only struct onto the shared common::modules::button_configuration machinery, using the same button_id convention already verified on 4+ other devices and traced byte-for-byte through the decompiled CmmBtCmdService.t2 and its ViewModel caller. Both are confirmed end to end on real hardware, including a physical button-behavior check for button config, not just protocol-level. - Also closes out a full-implementation audit of every A3953StateUpdatePacket field: one more candidate (adjustable ANC strength) was checked and ruled out, confirmed on real hardware that the official app doesn't expose it for this device. - Adds a CHANGELOG entry for both macOS support and the Liberty 4. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Traced it through the decompiled app: it's a single ASCII character, not a numeric id. Confirmed '2' = Cloud White against a real capture; the other three retail colors are unconfirmed, so they're left undocumented rather than guessed. Not exposed as a setting, matching every other device in this project (none of which expose case color). Co-Authored-By: Claude Sonnet 5 <[email protected]>
Found via a feature-parity audit against the A3947 and A3954: tws_status is already parsed from every state packet, since other features depend on it, but nothing exposed it as a setting. Wire up the existing shared module, matching both siblings. Confirmed on the real capture: Connected, host device Right. Co-Authored-By: Claude Sonnet 5 <[email protected]>
…53 device info - ensure_connected() now always closes an existing baseband connection before reopening it, instead of no-opping when the device is already connected. openRFCOMMChannelAsync can otherwise leave the channel permanently stuck "not open" if the device was already connected before connect() ran, confirmed to affect the CLI and GUI equally. Trade-off: an audible disconnect/reconnect on every connect now, not just when this bug would otherwise hit. - Also swaps the A3953's serial number/firmware and TWS status ordering on the device information page to match the A3947 (settings display in builder registration order, not sorted). Co-Authored-By: Claude Sonnet 5 <[email protected]>
- ensure_connected's comment named MACOS_RFCOMM_BRIEFING.md directly, an untracked, uncommitted local doc that doesn't ship with this project. Removed the reference, kept the explanation. Co-Authored-By: Claude Sonnet 5 <[email protected]>
|
Hi, I opened #374/#376 before noticing this PR — sorry for the overlap. A few observations from my own IOBluetooth implementation (macOS 15, Apple Silicon, Soundcore R50i) that may be useful here:
Happy to test this branch on my device if that helps. |
|
@afrijaldz No worries at all! I don't particularly care which branch is "the one" that gets merged in, just that it's as feature complete as possible while minimizing any bugs or feature disparity with the Linux / Windows implementation. IOBluetooth is a little bit quirky (as I am sure you know). I just wanted to get the functionality added since my main computer -- a MacBook Pro (M4 Pro) -- runs macOS (macOS 26 when I submitted this PR, but now running macOS 27), and the project did not currently support the Soundcore Liberty 4 model (for which there was already a GitHub Issue filed). The main thread run loop thing is worth looking into. My branch pumps a dedicated thread for RFCOMM channel events instead of the main thread, based on what I saw testing on macOS 26 / 27. If callbacks really are main-thread-only on macOS 15 like you're describing, mine might just hang there instead of obviously failing, which is worse. Any chance you'd pull my branch as-is and try a connect + EQ write on macOS 15? I don't have one to test against anymore, unfortunately. Also, for what it's worth, I went with objc2/objc2-io-bluetooth instead of hand-written Objective-C mainly to avoid needing a second toolchain (cc crate + build.rs just to compile a .m file) and manual retain / release bridging. Keeps the whole backend in Rust with typed bindings rather than a hand-rolled C ABI in between. Not intended to be a knock on your approach, just explaining the tradeoff I made. I think I will steal the MTU-chunked write regardless of how the threading question shakes out. A single writeSync for the whole buffer seems like an obvious bug for the bigger packets (EQ, button config) if the channel doesn't split that internally, and yours already handles it. I will say that I didn't run into any issues there, but macOS 15 may behave differently and I'd rather be safe than sorry! |
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]>
573e93f to
6ffc1f5
Compare
…igning The .app bundle had no LSMinimumSystemVersion/CFBundleSupportedPlatforms and was never signed, which Launch Services was misidentifying as an iOS app instead of a native macOS one. Adds both plist keys and an ad-hoc codesign step in build.sh after bundle assembly. Ad-hoc signing fixes the local build/Kind issue but isn't notarization: downloaded zips still need right-click -> Open, docs updated to reflect that. afrijaldz's competing macOS PR (Oppzippy#376) independently landed the same fix. Co-Authored-By: Afrijal Dzuhri <[email protected]> Co-Authored-By: Claude Sonnet 5 <[email protected]>
|
@afrijaldz I went ahead and added the MTU-chunked write fix. It was pulled pretty much straight from your rfcomm.m -- makes sense, and it's a documented requirement of Also fixed the app bundle showing up as "iOS" in Finder instead of "Apple Silicon". That turned out to be a missing LSMinimumSystemVersion / CFBundleSupportedPlatforms plus no codesign at all. Looks like you'd already landed the same fix independently in #376 too, so that's cool.
Since I referenced your stuff, I added you as a co-author on both of those commits. If you prefer that I remove you from those, just let me know. Now just waiting for your test of this branch for connect + EQ write stability on your macOS 15 setup when you get a chance since that's the one I actually can't verify myself. No rush! |
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.
…eband 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.
|
@SeanathanVT Pulled 1. Doesn't start on macOS 15 macOS 15 ships sqlite 3.43; the 2-arg 2. Connect fails on every other attempt once that's patched — a strict fail/ok/fail/ok pattern:
So the "hangs silently on macOS 15" worry didn't materialize: the open callback does arrive (on the main thread's run loop, since the CLI pumps that too). What breaks is the close/reopen cycle: 3. EQ writes work. Presets and button config persist, verified from a fresh process. Your MTU chunking is fine. Only gotcha: the device reports stale state if you reconnect within ~2–3 s of a write — same on my branch, not a backend thing. 4. Without the cycle at all, only sub-second back-to-back reconnects still fail (3/15): the previous process's channel teardown races the new open. I suspect that's the "stuck channel" you saw, rather than a reason to cycle on every connect. One more data point: I put what I ended up with on top of your branch here, as a reference for the macOS 15 case: SeanathanVT#1. Take whatever's useful or ignore it — it's mostly there as evidence of what fails on 15 and one way that fixes it (bundled sqlite; reuse an existing baseband connection; wait for The one thing I can't verify is 26/27: if the open callback really isn't dispatched until the first write there, the wait would always time out. If that's the case I'm happy to switch it to the more conservative "log and proceed on timeout" variant so your existing write-retry loop stays the safety net — just say the word. |
fix(macos): macOS 15 fixes for feature/apple-macos-support (sqlite, RFCOMM open handshake)
|
Thanks for the PR, @afrijaldz.. Tested and verified that everything still works as expected on macOS 27. Thanks for catching the macOS 15 incompatibility (which is something I couldn't test on my end)! |
- connection_status() no longer sticks at Connected forever after a local disconnect: rfcommChannelClosed: can't fire once the delegate is cleared, so send Disconnected explicitly during cleanup too. - Baseband cycle-and-retry fallback now covers first-time connects, not just reconnects; the underlying race isn't specific to either. - cycle_baseband_connection fails instead of opening into a baseband connection that never finished closing, which would just recreate the stuck-channel bug it exists to work around. - Write retry pumps the run loop instead of blindly sleeping, so a real disconnect during a transient write stall is caught instead of blocked through for up to several seconds. - dual_connections parser logs when a corrupt length byte forces a truncated read, instead of failing silently in that case. - Drop a redundant clamp on PressSensitivity's wire serialization; the only two producers already clamp at their input boundary. - Comment cleanup: drop a stale ponytail marker with no real upgrade path, an iteration-narrative comment, and two em-dashes. Co-Authored-By: Claude <[email protected]>
|
@afrijaldz Would you mind doing one more test on the current state of this branch? I merged your PR content in and did one more code review / cleanup pass. All tests succeeded and everything works as expected on macOS 27, but I can't replicate your setup for verification. |
|
@SeanathanVT Re-tested
LGTM from the macOS 15 side. Thanks a lot for folding the fix in and for the cleanup pass — the comments around the baseband cycling and the |
|
Thanks for the PR. Mind splitting this up into two separate PRs, one for macos and one for the new device? It's fine if the commit history is squashed. I'll review after that. |
- Soundcore Liberty 4 (A3953) device support, including the shared dual connections parser fix, now lives in its own PR at the maintainer's request. - No macOS backend changes; the two PRs don't depend on each other. Co-Authored-By: Claude <[email protected]>
|
@Oppzippy No worries. I pulled the A3953 stuff out of this PR and will create another one for that effort. Thank you! |

AI disclosure
Written using Claude Code (Sonnet 5) and OpenCode (Qwen 3.8 27B), with review and real-hardware testing by me throughout.
Summary
lib/src/connection_backend/macos), gated behindcfg(target_os = "macos")like the existing Windows/Linux backends.Non-obvious decisions
SDP lookups need the real main thread; RFCOMM data doesn't. The SDP completion callback only ever fires on the process's actual main thread. RFCOMM channel callbacks fire on whatever thread happens to be pumping a run loop at the time, which in practice is the dedicated connection thread this backend spins up. So tokio can't just own the OS main thread the usual way on macOS.
cli/src/main.rsruns tokio on a worker thread while the real main thread pumps the run loop.SDP timeout leaks its delegate on purpose. IOBluetooth doesn't retain the delegate object passed into a query, and there's no way to cancel one in flight. If a query times out, dropping the delegate risks a callback firing into freed memory later. On timeout it's leaked instead, at most once per timeout, not repeatedly.
RFCOMM can fail to connect if the device is already connected through another profile. Confirmed to affect the CLI and GUI equally. The channel open waits for the real
rfcommChannelOpenComplete:callback, and if that never arrives, the baseband connection is closed, confirmed closed, then reopened and the channel open retried once. Cycling the baseband is the only known workaround for this macOS quirk, but it costs an audible disconnect/reconnect chime, so it only happens as a fallback, not on every connect. Credit to @afrijaldz for the callback wait and the close race fix, found while testing on macOS 15. Confirmed working on real hardware.RFCOMM writes are split at the channel's MTU.
writeSync:length:isn't allowed to be called with more bytes than the negotiated MTU, perIOBluetoothRFCOMMChannel's own documented contract. The original write sent the whole buffer in one call regardless. Fixed by chunking at the MTU, falling back to one unsplit write if it reads back as0(can happen briefly right after connecting). Credit to @afrijaldz's macOS PR (#374) for flagging this.The built
.appbundle showed the wrong Kind in Finder ("iOS" instead of "Apple Silicon"). Caused by a missingLSMinimumSystemVersion/CFBundleSupportedPlatformsinInfo.plistand no code signing at all, both of which Launch Services needs to identify a bundle correctly. Fixed with both plist keys and an ad-hoccodesignstep after bundle assembly. Not notarization: a locally built copy now opens and identifies correctly, but a downloaded release zip still needs right-click → Open due to the quarantine flag. The same fix independently landed in #376.Verification
Tested on real hardware with my own Liberty 4 and Life P3.
cargo test,cargo clippy, andcargo fmt --checkpass on both the macOS and native targets.Screenshots (macOS)