diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a870ca070..c784466a0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 @@ -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 diff --git a/.github/workflows/test-macos.yml b/.github/workflows/test-macos.yml new file mode 100644 index 000000000..ef4ed6ada --- /dev/null +++ b/.github/workflows/test-macos.yml @@ -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 diff --git a/.gitignore b/.gitignore index 93072327c..bf67e01a2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ /build-output .vscode .cargo +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 07e6d4cba..c3404adc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### General + +#### Features + +- Add macOS support (beta) + ## v2.12.0 ### General diff --git a/Cargo.lock b/Cargo.lock index 264f578aa..adea63491 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3891,6 +3891,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.6.2", "dispatch2", + "libc", "objc2 0.6.4", ] @@ -3954,8 +3955,21 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-bluetooth" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558b52e7b77f6a68e1e2cc700ff2266779ede47bacb260a33d363b9a623cc761" +dependencies = [ + "libc", "objc2 0.6.4", "objc2-core-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -4083,6 +4097,7 @@ dependencies = [ "insta", "insta-cmd", "macaddr", + "objc2-core-foundation", "openscq30-i18n", "openscq30-lib", "serde", @@ -4156,6 +4171,10 @@ dependencies = [ "macaddr", "nom 8.0.0", "nom-language", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-io-bluetooth", "openscq30-i18n", "openscq30-i18n-macros", "openscq30-lib-has", diff --git a/Cargo.toml b/Cargo.toml index 6cfc19c59..5c70e1f27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/README.md b/README.md index b43bc426b..3dc8dd4d9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 21b2dff2a..b91156cc8 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -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 } diff --git a/cli/src/main.rs b/cli/src/main.rs index aa860593b..a495431d2 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -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) { diff --git a/docs/build-macos.md b/docs/build-macos.md new file mode 100644 index 000000000..1cad3e3e5 --- /dev/null +++ b/docs/build-macos.md @@ -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. diff --git a/justfile b/justfile index a0219ed26..0e7eac9ac 100644 --- a/justfile +++ b/justfile @@ -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.")] @@ -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" diff --git a/lib/Cargo.toml b/lib/Cargo.toml index f4fba3202..3bfd81ddc 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -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"] } diff --git a/lib/src/connection_backend.rs b/lib/src/connection_backend.rs index 94b562c06..3529982cb 100644 --- a/lib/src/connection_backend.rs +++ b/lib/src/connection_backend.rs @@ -8,6 +8,9 @@ cfg_select! { target_os = "windows" => { mod windows; } + target_os = "macos" => { + mod macos; + } _ => { mod none; } @@ -30,6 +33,9 @@ pub fn default_backends() -> Option { target_os = "windows" => { Some(windows::PlatformConnectionBackends) } + target_os = "macos" => { + Some(macos::PlatformConnectionBackends) + } _ => { None:: } diff --git a/lib/src/connection_backend/macos.rs b/lib/src/connection_backend/macos.rs new file mode 100644 index 000000000..cf043b2b9 --- /dev/null +++ b/lib/src/connection_backend/macos.rs @@ -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 { + Ok(rfcomm::MacosRfcommBackend) + } +} diff --git a/lib/src/connection_backend/macos/rfcomm.rs b/lib/src/connection_backend/macos/rfcomm.rs new file mode 100644 index 000000000..be6f5d9b7 --- /dev/null +++ b/lib/src/connection_backend/macos/rfcomm.rs @@ -0,0 +1,759 @@ +use std::{ + collections::HashSet, + ffi::{c_int, c_void}, + panic::Location, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread, +}; + +use async_trait::async_trait; +use macaddr::MacAddr6; +use objc2::{AnyThread, DefinedClass, define_class, rc::Retained, runtime::AnyObject}; +use objc2_core_foundation::{CFRunLoop, CFTimeInterval, kCFRunLoopDefaultMode}; +use objc2_foundation::{NSArray, NSObject, NSObjectProtocol, NSString}; +use objc2_io_bluetooth::{ + BluetoothRFCOMMChannelID, BluetoothSDPServiceAttributeID, IOBluetoothDevice, + IOBluetoothRFCOMMChannel, IOBluetoothSDPDataElement, IOBluetoothSDPServiceRecord, + IOBluetoothSDPUUID, +}; +use tokio::sync::{mpsc, watch}; +use tracing::{debug, debug_span, trace}; +use uuid::Uuid; + +use crate::{ + api::connection::{self, RfcommBackend, RfcommConnection}, + connection::RfcommServiceSelectionStrategy, +}; + +// Bluetooth SDP spec: the ServiceClassIDList attribute, which holds the UUIDs a service record advertises. +const SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID: BluetoothSDPServiceAttributeID = 0x0001; + +// A channel can briefly report "not open" right after connecting while IOBluetooth finishes the +// underlying L2CAP negotiation, even though openRFCOMMChannelAsync already returned success. How +// long this takes is inconsistent in practice (observed anywhere from ~200ms to over 2s), so the +// retry budget is generous rather than tight. +const IO_RETURN_NOT_OPEN: c_int = 0xE00002CDu32 as c_int; +const WRITE_NOT_OPEN_RETRIES: u32 = 50; + +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; + +// How long the connection thread waits for a write request before falling back to pumping the +// run loop; bounds how stale an unpumped inbound callback can get, so kept short. +const WRITE_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(10); + +#[derive(Default)] +pub struct MacosRfcommBackend; + +#[async_trait] +impl RfcommBackend for MacosRfcommBackend { + async fn devices(&self) -> connection::Result> { + tokio::task::spawn_blocking(|| { + // Skip devices whose address can't be read rather than failing the whole list. + Ok(paired_devices() + .into_iter() + .filter_map(|device| { + let mac_address = mac_address_of(&device).ok()?; + Some(connection::ConnectionDescriptor { + name: unsafe { device.nameOrAddress() } + .map(|name| name.to_string()) + .unwrap_or_default(), + mac_address, + }) + }) + .collect()) + }) + .await + .unwrap() + } + + async fn connect( + &self, + mac_address: MacAddr6, + service_selection_strategy: RfcommServiceSelectionStrategy, + ) -> connection::Result> { + tokio::task::spawn_blocking( + move || -> connection::Result> { + let span = debug_span!( + "RfcommBackend::connect", + mac_address = tracing::field::display(mac_address), + ); + let _span_guard = span.enter(); + + debug!("finding device with desired mac address"); + let device = device_with_mac_address(mac_address)?; + + 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"); + match MacosRfcommConnection::open(device.clone(), channel_id) { + Ok(connection) => Ok(Arc::new(connection)), + // The RFCOMM channel can get stuck "not open" forever regardless of whether + // the baseband connection was just opened or already existed. Cycling it 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 { .. }) => { + 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 + .unwrap() + } +} + +fn paired_devices() -> Vec> { + unsafe { IOBluetoothDevice::pairedDevices() } + .map(|devices| downcast_all::(&devices)) + .unwrap_or_default() +} + +// pairedDevices()/services()/getArrayValue() are untyped NSArray, so elements need a runtime downcast. +fn downcast_all(array: &NSArray) -> Vec> { + array + .to_vec() + .into_iter() + .filter_map(|element| element.downcast::().ok()) + .collect() +} + +fn mac_address_of(device: &IOBluetoothDevice) -> connection::Result { + let address_string = + unsafe { device.addressString() }.ok_or_else(|| connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + })?; + address_string + .to_string() + .replace('-', ":") + .parse() + .map_err(|err: macaddr::ParseError| connection::Error::Other { + source: Box::new(err), + location: Location::caller(), + }) +} + +fn device_with_mac_address( + mac_address: MacAddr6, +) -> connection::Result> { + let address_string = NSString::from_str(&mac_address.to_string().replace(':', "-")); + unsafe { IOBluetoothDevice::deviceWithAddressString(Some(&address_string)) }.ok_or( + connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }, + ) +} + +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 { + // Opening now would race the still-in-flight close: the exact precondition that + // leaves the RFCOMM channel permanently stuck (see BASEBAND_CLOSE_TIMEOUT above). + // Fail instead of guaranteeing that same failure. + return Err(connection::Error::TimedOut { + action: "close baseband connection", + }); + } + thread::sleep(BASEBAND_CLOSE_POLL_INTERVAL); + } + 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 { + source: None, + location: Location::caller(), + }); + } + Ok(()) +} + +fn select_channel_id( + device: &IOBluetoothDevice, + service_selection_strategy: &RfcommServiceSelectionStrategy, +) -> connection::Result { + let uuid = match service_selection_strategy { + RfcommServiceSelectionStrategy::Constant(uuid) => *uuid, + RfcommServiceSelectionStrategy::Dynamic(select_uuid) => { + let uuids = service_uuids(device); + debug!("found RFCOMM services: {uuids:?}"); + let uuid = select_uuid(uuids); + debug!("using RFCOMM service: {uuid}"); + uuid + } + }; + + let record = service_record_for_uuid(device, uuid)?; + let mut channel_id: BluetoothRFCOMMChannelID = 0; + let status = unsafe { record.getRFCOMMChannelID(&mut channel_id) }; + if status != 0 { + return Err(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }); + } + debug!("resolved RFCOMM channel id: {channel_id}"); + Ok(channel_id) +} + +fn service_record_for_uuid( + device: &IOBluetoothDevice, + uuid: Uuid, +) -> connection::Result> { + let sdp_uuid = sdp_uuid_from_uuid(uuid).ok_or(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + })?; + unsafe { device.getServiceRecordForUUID(Some(&sdp_uuid)) }.ok_or( + connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }, + ) +} + +// macOS only populates the SDP cache once System Settings' device info pane has been opened, so +// query if empty. Not forced on every connect: performSDPQuery can hang indefinitely when called +// repeatedly in quick succession. +fn service_uuids(device: &IOBluetoothDevice) -> HashSet { + let mut records = cached_service_records(device); + if records.is_empty() { + debug!("no cached SDP records, performing SDP query"); + if let Err(err) = perform_sdp_query(device) { + debug!("SDP query failed: {err}"); + return HashSet::new(); + } + records = cached_service_records(device); + } + debug!("found {} cached SDP service records", records.len()); + records + .into_iter() + .flat_map(|record| service_class_uuids(&record)) + .collect() +} + +fn cached_service_records( + device: &IOBluetoothDevice, +) -> Vec> { + unsafe { device.services() } + .map(|records| downcast_all::(&records)) + .unwrap_or_default() +} + +fn perform_sdp_query(device: &IOBluetoothDevice) -> connection::Result<()> { + let (done_sender, done_receiver) = std::sync::mpsc::channel(); + let delegate = SdpQueryDelegate::new(done_sender); + let delegate_object: &AnyObject = &delegate; + let status = unsafe { device.performSDPQuery(Some(delegate_object)) }; + if status != 0 { + return Err(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }); + } + + // sdpQueryComplete: is delivered on the process main thread (pumped continuously by + // cli/src/main.rs's run loop), not this thread, so a run loop pumped here would never see + // it; recv_timeout on the plain mpsc channel is what actually bounds the wait. + match done_receiver.recv_timeout(SDP_QUERY_TIMEOUT) { + Ok(0) => Ok(()), + Ok(_) => Err(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + // IOBluetoothDevice doesn't retain the target passed to performSDPQuery, and there's + // no way to cancel it, so the query may still be in flight here. Dropping delegate + // normally would risk sdpQueryComplete: firing into deallocated memory later; leak it + // instead (bounded: at most once per timed-out query). + std::mem::forget(delegate); + Err(connection::Error::TimedOut { + action: "SDP query", + }) + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + Err(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }) + } + } +} + +struct SdpQueryState { + done_sender: std::sync::mpsc::Sender, +} + +define_class!( + #[unsafe(super(NSObject))] + #[ivars = SdpQueryState] + struct SdpQueryDelegate; + + unsafe impl NSObjectProtocol for SdpQueryDelegate {} + + impl SdpQueryDelegate { + #[unsafe(method(sdpQueryComplete:status:))] + fn sdp_query_complete(&self, _device: Option<&IOBluetoothDevice>, status: c_int) { + let _ = self.ivars().done_sender.send(status); + } + } +); + +impl SdpQueryDelegate { + fn new(done_sender: std::sync::mpsc::Sender) -> Retained { + let this = Self::alloc().set_ivars(SdpQueryState { done_sender }); + unsafe { objc2::msg_send![super(this), init] } + } +} + +fn service_class_uuids(record: &IOBluetoothSDPServiceRecord) -> Vec { + let Some(element) = + (unsafe { record.getAttributeDataElement(SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID) }) + else { + debug!("record has no ServiceClassIDList attribute"); + return Vec::new(); + }; + // IOBluetooth surfaces a single-UUID ServiceClassIDList as a bare UUID element, not a 1-item array. + if let Some(uuid) = unsafe { element.getUUIDValue() } { + return uuid_from_sdp_uuid(&uuid).into_iter().collect(); + } + + // getArrayValue() doesn't recognize this element even when it's an array; getValue() + downcast does. + let sub_elements = + unsafe { element.getValue() }.and_then(|value| value.downcast::>().ok()); + let Some(sub_elements) = sub_elements else { + debug!( + "ServiceClassIDList attribute value is not an array (type descriptor: {:?})", + unsafe { element.getTypeDescriptor() } + ); + return Vec::new(); + }; + let sub_elements = downcast_all::(&sub_elements); + debug!("ServiceClassIDList has {} entries", sub_elements.len()); + sub_elements + .into_iter() + .filter_map(|sub_element| { + let uuid = unsafe { sub_element.getUUIDValue() }; + if uuid.is_none() { + debug!("ServiceClassIDList entry is not a UUID"); + } + uuid.and_then(|uuid| uuid_from_sdp_uuid(&uuid)) + }) + .collect() +} + +fn sdp_uuid_from_uuid(uuid: Uuid) -> Option> { + let bytes = uuid.into_bytes(); + unsafe { IOBluetoothSDPUUID::uuidWithBytes_length(bytes.as_ptr().cast(), bytes.len() as _) } +} + +fn uuid_from_sdp_uuid(sdp_uuid: &IOBluetoothSDPUUID) -> Option { + let normalized = unsafe { sdp_uuid.getUUIDWithLength(16) }?; + let bytes: [u8; 16] = normalized.to_vec().try_into().ok()?; + Some(Uuid::from_bytes(bytes)) +} + +struct ConnectionState { + open_complete_sender: std::sync::mpsc::Sender, + data_sender: mpsc::Sender>, + status_sender: watch::Sender, + running: Arc, +} + +define_class!( + #[unsafe(super(NSObject))] + #[ivars = ConnectionState] + struct RfcommDelegate; + + unsafe impl NSObjectProtocol for RfcommDelegate {} + + impl RfcommDelegate { + #[unsafe(method(rfcommChannelOpenComplete:status:))] + fn rfcomm_channel_open_complete( + &self, + _channel: Option<&IOBluetoothRFCOMMChannel>, + status: c_int, + ) { + debug!("rfcommChannelOpenComplete status: {status}"); + let _ = self.ivars().open_complete_sender.send(status); + } + + #[unsafe(method(rfcommChannelData:data:length:))] + fn rfcomm_channel_data( + &self, + _channel: Option<&IOBluetoothRFCOMMChannel>, + data: *mut c_void, + length: usize, + ) { + let bytes = unsafe { std::slice::from_raw_parts(data.cast::(), length) }.to_vec(); + trace!("received packet: {bytes:?}"); + // This callback runs on the connection thread, which also services writes and pumps + // the run loop, so blocking here would freeze the whole connection if the reader + // ever stalls: drop the packet instead of waiting for buffer space. + match self.ivars().data_sender.try_send(bytes) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + debug!("read_channel buffer full, dropping packet"); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + debug!("read_channel receiver is closed"); + } + } + } + + #[unsafe(method(rfcommChannelClosed:))] + fn rfcomm_channel_closed(&self, _channel: Option<&IOBluetoothRFCOMMChannel>) { + debug!("rfcomm channel closed"); + self.ivars().running.store(false, Ordering::Relaxed); + self.ivars() + .status_sender + .send_replace(connection::ConnectionStatus::Disconnected); + if let Some(run_loop) = CFRunLoop::current() { + run_loop.stop(); + } + } + } +); + +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, + }); + unsafe { objc2::msg_send![super(this), init] } + } +} + +struct SendableDevice(Retained); +unsafe impl Send for SendableDevice {} + +pub struct MacosRfcommConnection { + write_requests: + std::sync::mpsc::Sender<(Vec, std::sync::mpsc::Sender>)>, + read_channel: Mutex>>>, + connection_status_receiver: watch::Receiver, + running: Arc, +} + +impl MacosRfcommConnection { + fn open( + device: Retained, + channel_id: BluetoothRFCOMMChannelID, + ) -> connection::Result { + let (data_sender, data_receiver) = mpsc::channel(100); + let (status_sender, status_receiver) = + watch::channel(connection::ConnectionStatus::Connected); + let (open_result_sender, open_result_receiver) = std::sync::mpsc::channel(); + let (write_request_sender, write_request_receiver) = std::sync::mpsc::channel(); + let device = SendableDevice(device); + let running = Arc::new(AtomicBool::new(true)); + + thread::spawn({ + let running = running.clone(); + move || { + run_connection_thread( + device, + channel_id, + data_sender, + status_sender, + open_result_sender, + write_request_receiver, + running, + ) + } + }); + + open_result_receiver + .recv() + .map_err(|err| connection::Error::Other { + source: Box::new(err), + location: Location::caller(), + })??; + + Ok(Self { + write_requests: write_request_sender, + read_channel: Mutex::new(Some(data_receiver)), + connection_status_receiver: status_receiver, + running, + }) + } +} + +// 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); + } +} + +// writeSync:length: must not be called with more bytes than the channel's negotiated MTU (per +// IOBluetoothRFCOMMChannel's own documented contract). MTU can read back as 0 in the brief +// window right after connecting before it's negotiated (see WRITE_NOT_OPEN_RETRIES below); +// fall back to one unsplit write in that case rather than guessing a default. +fn do_write(channel: &IOBluetoothRFCOMMChannel, data: &[u8]) -> connection::Result<()> { + let mtu = usize::from(unsafe { channel.getMTU() }); + let chunk_size = if mtu == 0 { data.len().max(1) } else { mtu }; + for chunk in data.chunks(chunk_size) { + write_chunk(channel, chunk)?; + } + trace!("wrote packet: {data:?}"); + Ok(()) +} + +fn write_chunk(channel: &IOBluetoothRFCOMMChannel, chunk: &[u8]) -> connection::Result<()> { + let mut buffer = chunk.to_vec(); + let mut status; + let mut attempt = 0; + loop { + status = + unsafe { channel.writeSync_length(buffer.as_mut_ptr().cast(), buffer.len() as u16) }; + attempt += 1; + if status != IO_RETURN_NOT_OPEN || attempt >= WRITE_NOT_OPEN_RETRIES { + break; + } + debug!("channel not open yet, retrying write ({attempt})"); + // wait_for_open_complete already confirmed the channel opened once before any write is + // attempted, so a not-open status here is a transient stall on a live connection, not + // the initial-open race: pump so a real rfcommChannelClosed: during the stall is caught + // instead of blocking through it. + pump_run_loop(); + } + debug!("writeSync status: {status}, channel isOpen: {}", unsafe { + channel.isOpen() + }); + if status != 0 { + return Err(connection::Error::WriteError { + source: None, + location: Location::caller(), + }); + } + Ok(()) +} + +fn run_connection_thread( + device: SendableDevice, + channel_id: BluetoothRFCOMMChannelID, + data_sender: mpsc::Sender>, + status_sender: watch::Sender, + open_result_sender: std::sync::mpsc::Sender>, + write_request_receiver: std::sync::mpsc::Receiver<( + Vec, + std::sync::mpsc::Sender>, + )>, + running: Arc, +) { + let span = debug_span!("MacosRfcommConnection connection thread"); + let _span_guard = span.enter(); + + let (open_complete_sender, open_complete_receiver) = std::sync::mpsc::channel(); + let delegate = RfcommDelegate::new( + open_complete_sender, + data_sender, + status_sender.clone(), + running.clone(), + ); + + let mut channel: Option> = None; + let delegate_object: &AnyObject = &delegate; + let start_status = unsafe { + device.0.openRFCOMMChannelAsync_withChannelID_delegate( + Some(&mut channel), + channel_id, + Some(delegate_object), + ) + }; + + 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 { + source: None, + location: Location::caller(), + })); + 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 + // pumps the run loop here, unlike SDP query callbacks which need the real main thread + // (see perform_sdp_query), so writes and pumping stay on this dedicated thread. + debug!("channel opened, running write/pump loop"); + + // Service any write requests queued while the channel was still opening. + for (data, response_sender) in write_request_receiver.try_iter() { + let _ = response_sender.send(do_write(&channel, &data)); + } + + while running.load(Ordering::Relaxed) { + match write_request_receiver.recv_timeout(WRITE_DRAIN_TIMEOUT) { + Ok((data, response_sender)) => { + let _ = response_sender.send(do_write(&channel, &data)); + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + } + if running.load(Ordering::Relaxed) { + pump_run_loop(); + } + } + + debug!("run loop stopped, draining pending write requests"); + // The caller's `write()` is blocked on recv() waiting for a status. If we exit without a + // response it would hang forever, so answer all still-queued writes with a clean failure + // before closing the channel. + for (_, response_sender) in write_request_receiver.try_iter() { + let _ = response_sender.send(Err(connection::Error::WriteError { + source: None, + location: Location::caller(), + })); + } + + debug!("cleaning up channel"); + unsafe { + channel.setDelegate(None); + channel.closeChannel(); + } + // rfcommChannelClosed: (the only other place this is sent) can't fire once the delegate + // above is cleared, so a locally-initiated close would otherwise leave connection_status() + // stuck reporting Connected forever. + status_sender.send_replace(connection::ConnectionStatus::Disconnected); + debug!("connection thread exiting"); +} + +#[async_trait] +impl RfcommConnection for MacosRfcommConnection { + async fn write(&self, data: &[u8]) -> connection::Result<()> { + let (response_sender, response_receiver) = std::sync::mpsc::channel(); + self.write_requests + .send((data.to_owned(), response_sender)) + .map_err(|err| connection::Error::Other { + source: Box::new(err), + location: Location::caller(), + })?; + tokio::task::spawn_blocking(move || response_receiver.recv()) + .await + .expect("connection thread panicked while handling a write") + .map_err(|err| connection::Error::Other { + source: Box::new(err), + location: Location::caller(), + })? + } + + fn read_channel(&self) -> mpsc::Receiver> { + self.read_channel + .lock() + .unwrap() + .take() + .expect("read_channel may only be called once per MacosRfcommConnection") + } + + fn connection_status(&self) -> watch::Receiver { + self.connection_status_receiver.clone() + } +} + +impl Drop for MacosRfcommConnection { + fn drop(&mut self) { + // The write-request sender is dropped with this struct, which unblocks the connection + // thread (it sees Disconnected, or running flips and it exits), and it cleans up the + // channel (clears the delegate, closes it) on its own thread. + self.running.store(false, Ordering::Relaxed); + } +} diff --git a/lib/src/devices/soundcore/common/packet/packet_io_controller.rs b/lib/src/devices/soundcore/common/packet/packet_io_controller.rs index eb10a6e00..ac27015dd 100644 --- a/lib/src/devices/soundcore/common/packet/packet_io_controller.rs +++ b/lib/src/devices/soundcore/common/packet/packet_io_controller.rs @@ -61,6 +61,7 @@ impl PacketIOController { ) -> (JoinHandle<()>, mpsc::Receiver) { let (outgoing_sender, outgoing_receiver) = mpsc::channel(100); let handle = tokio::spawn(async move { + tracing::debug!("packet handler started"); let mut buffer = Vec::::new(); 'receive_packet: @@ -98,6 +99,7 @@ impl PacketIOController { bytes.extend_from_slice(&buffer[start_index..]); buffer = bytes; } + tracing::debug!("packet handler stopped: incoming channel closed"); }.instrument(info_span!("packet handler"))); (handle, outgoing_receiver) } @@ -126,12 +128,15 @@ impl PacketIOController { .await .is_ok() { + tracing::debug!("got response on attempt {i}"); return Ok(handle.wait_for_value().await); } + tracing::debug!("no response within {}ms", 500 * i); } self.packet_queues.cancel(&queue_key, handle); + tracing::warn!("gave up after 3 attempts, no response received"); Err(device::Error::ActionTimedOut { action: "resending packet until ack received", }) diff --git a/packaging/macos/Info.plist b/packaging/macos/Info.plist new file mode 100644 index 000000000..ca93e375b --- /dev/null +++ b/packaging/macos/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleName + OpenSCQ30 + CFBundleDisplayName + OpenSCQ30 + CFBundleIdentifier + com.oppzippy.OpenSCQ30 + CFBundleVersion + 2.12.0 + CFBundleShortVersionString + 2.12.0 + CFBundleExecutable + openscq30-gui + CFBundlePackageType + APPL + LSMinimumSystemVersion + 11.0 + CFBundleSupportedPlatforms + + MacOSX + + NSHighResolutionCapable + + NSBluetoothAlwaysUsageDescription + OpenSCQ30 connects to your headphones over Bluetooth to read and change their settings. + + diff --git a/packaging/macos/build.sh b/packaging/macos/build.sh new file mode 100755 index 000000000..9de742738 --- /dev/null +++ b/packaging/macos/build.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_path="$(readlink -f -- "$0")" +script_dir="$(dirname -- "$script_path")" +project_root="$script_dir/../.." +input_binary="$project_root/build-output/openscq30-gui" +app_dir="$project_root/build-output/OpenSCQ30.app" + +rm -rf "$app_dir" +mkdir -p "$app_dir/Contents/MacOS" + +cp "$script_dir/Info.plist" "$app_dir/Contents/Info.plist" +cp "$input_binary" "$app_dir/Contents/MacOS/openscq30-gui" +chmod +x "$app_dir/Contents/MacOS/openscq30-gui" + +# Ad-hoc signature so Launch Services identifies the bundle correctly and Gatekeeper allows +# running a locally built app. Not a substitute for real notarization: downloaded (quarantined) +# builds still need right-click -> Open. +codesign --force --sign - "$app_dir"