Skip to content

Add macOS support - #372

Open
SeanathanVT wants to merge 24 commits into
Oppzippy:masterfrom
SeanathanVT:feature/apple-macos-support
Open

SeanathanVT wants to merge 24 commits into
Oppzippy:masterfrom
SeanathanVT:feature/apple-macos-support

Conversation

@SeanathanVT

@SeanathanVT SeanathanVT commented Sep 14, 2026 •

Copy link
Copy Markdown

AI disclosure

Written using Claude Code (Sonnet 5) and OpenCode (Qwen 3.8 27B), with review and real-hardware testing by me throughout.

Summary

  • New macOS RFCOMM backend via IOBluetooth (lib/src/connection_backend/macos), gated behind cfg(target_os = "macos") like the existing Windows/Linux backends.
    • macOS listed as beta in the README (feel free to change this); CI builds and tests it.
  • Soundcore Liberty 4 (A3953) support was split out into feat(lib): add support for the Soundcore Liberty 4 (A3953) #384 at your request. The two PRs don't depend on each other.

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.rs runs 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, per IOBluetoothRFCOMMChannel'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 as 0 (can happen briefly right after connecting). Credit to @afrijaldz's macOS PR (#374) for flagging this.

The built .app bundle showed the wrong Kind in Finder ("iOS" instead of "Apple Silicon"). Caused by a missing LSMinimumSystemVersion/CFBundleSupportedPlatforms in Info.plist and no code signing at all, both of which Launch Services needs to identify a bundle correctly. Fixed with both plist keys and an ad-hoc codesign step 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, and cargo fmt --check pass on both the macOS and native targets.

Screenshots (macOS)

Screenshot 2026-09-14 at 12 34 46 Screenshot 2026-09-14 at 12 34 56 Screenshot 2026-09-14 at 12 35 00 Screenshot 2026-09-14 at 12 35 05 Screenshot 2026-09-14 at 12 35 10 Screenshot 2026-09-14 at 12 35 13 Screenshot 2026-09-14 at 12 35 17

SeanathanVT and others added 17 commits September 11, 2026 23:42
- 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]>
@afrijaldz

Copy link
Copy Markdown

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:

  1. rfcommChannelOpenComplete: is delivered reliably for me, but always on the main thread's run loop, regardless of which thread called openRFCOMMChannelAsync. Same for rfcommChannelData: and rfcommChannelClosed:. Since run_connection_thread pumps its own run loop, it may simply never see that callback, which could be why it looks unreliable. Waiting for rfcommChannelOpenComplete: on a semaphore (with the main loop pumped by cli/src/main.rs / winit) removed the need for both the kIOReturnNotOpen write-retry loop and the closeConnection() cycling in ensure_connected — so no audio chime on every connect. Worth checking whether the data callbacks you observed on the connection thread are consistent across macOS versions, or whether they were arriving on the main thread too.
  2. writeSync with the whole buffer: do_write passes buffer.len() as u16 in one call. Splitting at [channel getMTU] avoided write failures for me on larger packets (e.g. equalizer / button config).

Happy to test this branch on my device if that helps.

@SeanathanVT

Copy link
Copy Markdown
Author

@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]>
@SeanathanVT
SeanathanVT force-pushed the feature/apple-macos-support branch from 573e93f to 6ffc1f5 Compare September 17, 2026 14:52
…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]>
@SeanathanVT

SeanathanVT commented Sep 17, 2026 •

Copy link
Copy Markdown
Author

@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 writeSync:length: anyway. Good catch.

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.

image

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

Copy link
Copy Markdown

@SeanathanVT Pulled 404318de as-is and tested on macOS 15.4 / Apple Silicon / R50i. Short version: it doesn't work on 15 out of the box, but the reasons are pretty narrow.

1. Doesn't start on macOS 15

Error: storage: sql error: malformed database schema (quick_preset) - wrong number of arguments to function json_valid()

macOS 15 ships sqlite 3.43; the 2-arg json_valid() needs 3.45+. Needs rusqlite with bundled under cfg(target_os = "macos"), like Windows/Android already have. You wouldn't see this on 26/27.

2. Connect fails on every other attempt once that's patched — a strict fail/ok/fail/ok pattern:

  • device not baseband-connected → openConnection (~3.5 s) → rfcommChannelOpenComplete arrives → works
  • device already connected → ensure_connected does closeConnection() and then openConnection() + openRFCOMMChannelAsync in the same millisecond → the callback never arrives → 49 retries → kIOReturnNotOpen (-536870195). That leaves the device disconnected, so the next run takes the first path and succeeds — hence the alternation.

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: closeConnection is async and isConnected() stays true for ~0.5 s afterwards; reopening inside that window leaves the channel permanently not-open. Just waiting for isConnected() to go false before reopening made the cycle work 6/6.

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: isOpen() flips to true ~200 ms before rfcommChannelOpenComplete, and writes in that window fail with 0xE00002E7 (not kIOReturnNotOpen, so the retry loop doesn't catch it). Only the callback is trustworthy — I tried using isOpen() as a readiness signal and went 0/12.

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 rfcommChannelOpenComplete with a timeout; cycle the baseband only as a fallback, waiting for isConnected() to drop first). On 15: 15/15 back-to-back connect + EQ write, 10/10 with a 1 s gap, ~0.5 s connect, no chime.

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

Copy link
Copy Markdown
Author

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

Copy link
Copy Markdown
Author

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

@afrijaldz

Copy link
Copy Markdown

@SeanathanVT Re-tested 617b0bd3 on macOS 15.4 (Apple Silicon) with a Soundcore R50i (A3949):

  • CLI list-settings ×5 back-to-back: 5/5 succeeded. Run 4 hit the stuck "not open" channel; the baseband cycle kicked in and recovered it automatically (~7 s, with the audio chime as expected).
  • CLI setting -s presetEqualizerProfile=Jazz → -g → back to SoundcoreSignature: round-trips correctly.
  • GUI: connect, state read, disconnect — clean run loop stopped → cleaning up channel → connection thread exiting, no hang.
  • cargo test -p openscq30-lib: 202 passed.

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 write_chunk pump are clearer than what I had, and the Disconnected status on thread exit is a nice catch. Really appreciate you carrying this across the finish line; looking forward to having macOS support upstream.

@Oppzippy

Copy link
Copy Markdown
Owner

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]>
@SeanathanVT SeanathanVT changed the title Add macOS support and Soundcore Liberty 4 (A3953) support Add macOS support Sep 25, 2026
@SeanathanVT

Copy link
Copy Markdown
Author

@Oppzippy No worries. I pulled the A3953 stuff out of this PR and will create another one for that effort. Thank you!

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