Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
331bd32
feat(macos): add macOS RFCOMM backend via IOBluetooth
SeanathanVT Sep 11, 2026
6c550c0
Add .DS_Store (MacOS artifact) to .gitignore.
SeanathanVT Sep 11, 2026
97a6643
feat(lib): add basic support for the Soundcore Liberty 4 (A3953)
SeanathanVT Sep 12, 2026
777926f
feat(lib): add ambient sound mode support for the Soundcore Liberty 4…
SeanathanVT Sep 12, 2026
23592d8
feat(lib): add wear detection, LDAC, auto power off, wearing tone, case
SeanathanVT Sep 12, 2026
47ef0fc
feat(lib): add low battery prompt, ambient sound prompt, dual
SeanathanVT Sep 12, 2026
a8cbcaf
feat(lib): add custom equalizer support and decode button action name…
SeanathanVT Sep 12, 2026
2617bfa
fix(macos): harden RFCOMM transport, trim a3953 dead weight and comme…
SeanathanVT Sep 13, 2026
fe05282
feat(lib): wire full dual-connections support into the A3953, fix a s…
SeanathanVT Sep 13, 2026
8d0ed1d
fix(macos): prevent use-after-free in SDP query delegate on timeout
SeanathanVT Sep 14, 2026
632407e
chore(lib): remove dead code and align a3953 with codebase style conv…
SeanathanVT Sep 14, 2026
c93fd2c
feat(lib): add ambient-sound-mode-cycle and button config write suppo…
SeanathanVT Sep 14, 2026
1148180
docs(lib): document the a3953 device_colour byte's meaning
SeanathanVT Sep 14, 2026
53c29ed
feat(lib): expose TWS status and host device for the A3953
SeanathanVT Sep 14, 2026
279d3e2
fix(macos): force reconnect cycle when already connected; reorder a39…
SeanathanVT Sep 14, 2026
a58a28e
docs(macos): drop a reference to an untracked file from a code comment
SeanathanVT Sep 14, 2026
ce7a2b9
Merge branch 'Oppzippy:master' into feature/apple-macos-support
SeanathanVT Sep 15, 2026
6ffc1f5
fix(macos): split RFCOMM writes at the channel MTU
SeanathanVT Sep 17, 2026
404318d
fix(macos): fix app bundle showing wrong Kind in Finder, add ad-hoc s…
SeanathanVT Sep 17, 2026
b7166ae
fix(macos): bundle sqlite so the CLI/GUI start on macOS 15
afrijaldz Sep 17, 2026
48c00a2
fix(macos): wait for rfcommChannelOpenComplete and only cycle the bas…
afrijaldz Sep 17, 2026
6ddee0c
Merge pull request #1 from afrijaldz/fix/macos-rfcomm-open-handshake
SeanathanVT Sep 17, 2026
617b0bd
fix(macos): fix connection-status, retry, and stuck-channel edge cases
SeanathanVT Sep 17, 2026
0af8c97
chore: move A3953 support to its own PR
SeanathanVT Sep 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ on:
required: true
default: true
type: boolean
macos:
description: "Enable macOS build"
required: true
default: true
type: boolean

permissions:
contents: read
Expand Down Expand Up @@ -141,6 +146,48 @@ jobs:
name: windows
path: build-output/*

build-macos:
runs-on: macos-latest
if: inputs.macos
permissions:
id-token: write
attestations: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install just
run: brew install just
- name: Install rust
run: rustup install --profile minimal stable

- name: Build cli
run: just build-cli
- name: Build gui
run: just build-gui
- name: Build app bundle
run: just build-gui-bundle

- name: Add platform to file name, zip app bundle
run: |
mv build-output/openscq30 build-output/openscq30-cli-macos-arm64
rm build-output/openscq30-gui
cd build-output
zip -r openscq30-gui-macos-arm64.app.zip OpenSCQ30.app
rm -rf OpenSCQ30.app

- name: Attest artifacts
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-path: build-output/*

- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: macos
path: build-output/*

build-android:
runs-on: ubuntu-latest
if: inputs.android
Expand Down
31 changes: 31 additions & 0 deletions .github/workflows/test-macos.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: test macos

on:
push:
paths: &push_paths
- Cargo.lock
- lib/**
- .github/workflows/test-macos.yml
pull_request:
paths: *push_paths

permissions:
contents: read

jobs:
# Only lib is tested on macos since building the gui/cli would take
# a lot longer, and they don't contain any platform specific code
test-rust:
runs-on: macos-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install just
run: brew install just
- name: Install rust
run: rustup install --profile minimal stable
- name: Cache cargo dependencies
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Run lib tests
run: just lib/ test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
/build-output
.vscode
.cargo
.DS_Store
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## Unreleased

### General

#### Features

- Add macOS support (beta)

## v2.12.0

### General
Expand Down
19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ embed-resource = "3"
pathfinding = "4"
palette = "0.7"
bitflags = "2"
objc2 = "0.6"
objc2-foundation = "0.3"
objc2-core-foundation = "0.3"
objc2-io-bluetooth = "0.3"

[patch.'https://github.com/wash2/accesskit']
accesskit = { git = "https://github.com/Oppzippy/accesskit.git", branch = "iced-0.14" }
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ OpenSCQ30 is free software for controlling settings for Soundcore headphones and

[x] Windows - Ready
[x] Linux - Ready
[x] Android - Ready
[x] Android - Ready
[ ] MacOS - Beta

### Supported Devices

Expand Down
4 changes: 4 additions & 0 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ serde = { workspace = true }
serde_json = { workspace = true }
indexmap = { workspace = true, features = ["serde"] }

[target.'cfg(target_os = "macos")'.dependencies]
tokio = { workspace = true, features = ["rt-multi-thread"] }
objc2-core-foundation = { workspace = true }

[dev-dependencies]
insta = { workspace = true }
insta-cmd = { workspace = true }
Expand Down
46 changes: 46 additions & 0 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,54 @@ use openscq30_lib::OpenSCQ30Session;
use tracing::level_filters::LevelFilter;
use tracing_subscriber::EnvFilter;

#[cfg(not(target_os = "macos"))]
#[tokio::main]
async fn main() -> ExitCode {
run().await
}

// IOBluetooth delivers delegate callbacks (RFCOMM data, channel open/close) through the
// process's main thread run loop regardless of which thread issued the call, so tokio can't be
// left owning the real OS main thread here the way #[tokio::main] normally would: nothing would
// ever pump a run loop on it, and every callback would silently never arrive.
#[cfg(target_os = "macos")]
fn main() -> ExitCode {
let (exit_code_sender, exit_code_receiver) = std::sync::mpsc::channel();
let join_handle = std::thread::spawn(move || {
let runtime = tokio::runtime::Runtime::new().expect("failed to start tokio runtime");
let _ = exit_code_sender.send(runtime.block_on(run()));
});

loop {
match exit_code_receiver.try_recv() {
Ok(code) => return code,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
// The worker thread panicked before sending a result (the panic hook already
// printed it). Match the exit code Rust's default runtime uses when the real
// main thread panics, since the actual work here runs on a worker thread instead.
return match join_handle.join() {
Ok(()) => ExitCode::FAILURE,
Err(_) => ExitCode::from(101),
};
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
}
let result = unsafe {
objc2_core_foundation::CFRunLoop::run_in_mode(
objc2_core_foundation::kCFRunLoopDefaultMode,
0.1,
false,
)
};
// run_in_mode returns immediately, not after the given timeout, whenever nothing is
// scheduled on the run loop yet: sleep here so that doesn't turn into a busy spin.
if result == objc2_core_foundation::CFRunLoopRunResult::Finished {
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
}

async fn run() -> ExitCode {
let matches = cli::build().get_matches();

if let Err(err) = initialize_logging(&matches) {
Expand Down
24 changes: 24 additions & 0 deletions docs/build-macos.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## Building openscq30-cli on macOS

1. Install rust
2. Install the Xcode command line tools (`xcode-select --install`)
3. Run `cargo build --package openscq30-cli --profile release-fast` (or `cargo build --package openscq30-cli --release`, but it's very slow to build)
4. The compiled binary can be found at `target/release-fast/openscq30`

## Building openscq30-gui on macOS

### Just the executable

1. Install rust
2. Install the Xcode command line tools (`xcode-select --install`)
3. Run `cargo build --package openscq30-gui --profile release-fast` (or `cargo build --package openscq30-gui --release`, but it's very slow to build)
4. The compiled binary can be found at `target/release-fast/openscq30-gui`

### App bundle

1. Install rust, the Xcode command line tools, and [just](https://github.com/casey/just)
2. Run `just build-gui-fast` (or `just build-gui`, but it's very slow to build)
3. Run `just build-gui-bundle`
4. The app bundle can be found at `build-output/OpenSCQ30.app`

The app bundle is ad-hoc signed but not notarized. A locally built copy should open normally. A copy downloaded from elsewhere (e.g. a release zip) will still have the quarantine flag set, so macOS will refuse to open it with a plain double click; right click the app and choose "Open" instead, then confirm in the dialog that appears.
10 changes: 10 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ list:
[group("build")]
build-gui features='': create-build-output-dir
just gui::build release '{{ features }}'
rm -f '{{ build-output-dir }}/openscq30-gui'
cp target/release/openscq30-gui '{{ build-output-dir }}/'

[doc("Run a release build with excessively slow optimizations disabled")]
[group("build")]
build-gui-fast features='': create-build-output-dir
just gui::build release-fast '{{ features }}'
rm -f '{{ build-output-dir }}/openscq30-gui'
cp target/release-fast/openscq30-gui '{{ build-output-dir }}/'

[doc("Build the windows installer. The gui must be built first.")]
Expand All @@ -33,16 +35,24 @@ build-gui-installer: create-build-output-dir
./packaging/windows/build.sh
cp packaging/windows/Output/openscq30-gui-installer.exe '{{ build-output-dir }}/'

[doc("Build the macos app bundle. The gui must be built first.")]
[group("build")]
[macos]
build-gui-bundle: create-build-output-dir
./packaging/macos/build.sh

[doc("Run a fully optimized release build")]
[group("build")]
build-cli features='': create-build-output-dir
just cli::build release '{{ features }}'
rm -f '{{ build-output-dir }}/openscq30'
cp target/release/openscq30 '{{ build-output-dir }}/'

[doc("Run a release build with excessively slow optimizations disabled")]
[group("build")]
build-cli-fast features='': create-build-output-dir
just cli::build release-fast '{{ features }}'
rm -f '{{ build-output-dir }}/openscq30'
cp target/release-fast/openscq30 '{{ build-output-dir }}/'

android-apk-path := "./android/app/build/outputs/apk"
Expand Down
16 changes: 16 additions & 0 deletions lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ windows = { workspace = true, features = [
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 }
objc2-core-foundation = { workspace = true }
objc2-io-bluetooth = { workspace = true, features = [
"Bluetooth",
"IOBluetoothDevice",
"IOBluetoothRFCOMMChannel",
"IOBluetoothSDPServiceRecord",
"IOBluetoothSDPUUID",
"IOBluetoothSDPDataElement",
] }

[dev-dependencies]
tokio = { workspace = true, features = ["macros", "test-util"] }
proptest = { workspace = true, default-features = false, features = ["std"] }
Expand Down
6 changes: 6 additions & 0 deletions lib/src/connection_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ cfg_select! {
target_os = "windows" => {
mod windows;
}
target_os = "macos" => {
mod macos;
}
_ => {
mod none;
}
Expand All @@ -30,6 +33,9 @@ pub fn default_backends() -> Option<impl ConnectionBackends> {
target_os = "windows" => {
Some(windows::PlatformConnectionBackends)
}
target_os = "macos" => {
Some(macos::PlatformConnectionBackends)
}
_ => {
None::<none::NoneConnectionBackends>
}
Expand Down
14 changes: 14 additions & 0 deletions lib/src/connection_backend/macos.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
mod rfcomm;

use crate::{api::connection, connection_backend::ConnectionBackends};

#[derive(Default)]
pub struct PlatformConnectionBackends;

impl ConnectionBackends for PlatformConnectionBackends {
type Rfcomm = rfcomm::MacosRfcommBackend;

async fn rfcomm(&self) -> connection::Result<Self::Rfcomm> {
Ok(rfcomm::MacosRfcommBackend)
}
}
Loading
Loading