From 1916d5d4690ffe75586d3f6291bbda4c701eea3c Mon Sep 17 00:00:00 2001 From: injkgz Date: Fri, 4 Sep 2026 11:12:44 +0700 Subject: [PATCH 1/5] fix(linux): stop fighting the A2DP profile on playback start Three related problems made playback controls unreliable on Linux. MediaController re-activated its cached A2DP profile on every playback start, even when the card was already using another A2DP variant. Switching profiles recreates the PipeWire sink and stops the stream that triggered the activation, so a play command from the AirPods stem was cancelled about half a second later by the resulting pause. Any active a2dp-sink variant is now left untouched, which also keeps the negotiated codec unchanged. Fixes #680. The cached PulseAudio/PipeWire card index went stale across reconnects, since PipeWire may assign a different index. Availability checks then queried a card that no longer existed and concluded A2DP was unavailable. The card is now resolved by Bluetooth MAC before each activation and the cached index refreshed. Fixes #681. A freshly connected card can appear before its profiles are enumerated. The first missing-profile result triggered an immediate WirePlumber restart, which interrupts audio for several seconds and races pipewire-pulse rebuilding the card. Enumeration now gets a bounded grace period, the restart remains as a last resort, and post-restart retries are bounded too, without sleeping after the final attempt. Fixes #682. --- linux-rust/src/media_controller.rs | 177 ++++++++++++++++++----------- 1 file changed, 111 insertions(+), 66 deletions(-) diff --git a/linux-rust/src/media_controller.rs b/linux-rust/src/media_controller.rs index 5bd68b1a2..03c293bcc 100644 --- a/linux-rust/src/media_controller.rs +++ b/linux-rust/src/media_controller.rs @@ -29,6 +29,7 @@ struct OwnedCardInfo { index: u32, proplist: Proplist, profiles: Vec, + active_profile: Option, } #[derive(Clone, Debug)] @@ -301,75 +302,133 @@ impl MediaController { } } + /// Number of one-second attempts to wait for A2DP profiles to be enumerated. + const A2DP_ENUMERATION_ATTEMPTS: u32 = 5; + + /// Currently active profile of the card, used to avoid pointless switches. + async fn get_active_profile(&self, card_index: u32) -> Option { + tokio::task::spawn_blocking(move || { + get_card_info_list_sync() + .iter() + .find(|c| c.index == card_index) + .and_then(|c| c.active_profile.clone()) + }) + .await + .unwrap_or(None) + } + + /// Resolves the card by Bluetooth MAC and refreshes the cached index. + /// PipeWire can assign a different index after a disconnect/reconnect. + async fn refresh_device_index(&self) -> Option { + let mac = self.state.lock().await.connected_device_mac.clone(); + if mac.is_empty() { + return None; + } + let index = self.get_audio_device_index(&mac).await; + self.state.lock().await.device_index = index; + index + } + + /// Waits for the card to expose an A2DP profile, re-resolving the card on + /// every attempt. Sleeps between attempts, but never after the last one. + async fn wait_for_a2dp_profile(&self, attempts: u32) -> bool { + for attempt in 0..attempts { + if self.refresh_device_index().await.is_some() && self.is_a2dp_profile_available().await + { + return true; + } + if attempt + 1 < attempts { + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + false + } + + async fn restart_wire_plumber(&self) -> bool { + info!("Restarting WirePlumber to rediscover A2DP profiles"); + let result = Command::new("systemctl") + .args(["--user", "restart", "wireplumber"]) + .output(); + + match result { + Ok(output) if output.status.success() => { + info!("WirePlumber restarted successfully"); + tokio::time::sleep(Duration::from_secs(2)).await; + true + } + _ => { + error!("Failed to restart WirePlumber. Do you use wireplumber?"); + false + } + } + } + pub async fn activate_a2dp_profile(&self) { debug!("Entering activate_a2dp_profile"); - let state = self.state.lock().await; - if state.connected_device_mac.is_empty() { + if self.state.lock().await.connected_device_mac.is_empty() { warn!("Connected device MAC is empty, cannot activate A2DP profile"); return; } - let device_index = state.device_index; - let mac = state.connected_device_mac.clone(); - drop(state); - - let mut current_device_index = device_index; - - if current_device_index.is_none() { - warn!("Device index not found, trying to get it."); - current_device_index = self.get_audio_device_index(&mac).await; - if let Some(idx) = current_device_index { - self.state.lock().await.device_index = Some(idx); - } else { - warn!("Could not get device index. Cannot activate A2DP profile."); - return; - } + // Always resolve the card by MAC first: a reconnect can change its index. + if self.refresh_device_index().await.is_none() { + warn!("Could not get device index. Cannot activate A2DP profile."); + return; } if !self.is_a2dp_profile_available().await { - warn!("A2DP profile not available, attempting to restart WirePlumber"); - if self.restart_wire_plumber().await { - let mut state = self.state.lock().await; - state.device_index = self - .get_audio_device_index(&state.connected_device_mac) - .await; - debug!( - "Updated device_index after WirePlumber restart: {:?}", - state.device_index - ); - if !self.is_a2dp_profile_available().await { - error!("A2DP profile still not available after WirePlumber restart"); + // A freshly connected card can show up before its profiles are + // enumerated. Give that a grace period before restarting + // WirePlumber, which interrupts playback for several seconds. + warn!("A2DP profile not available yet, waiting for enumeration"); + if !self + .wait_for_a2dp_profile(Self::A2DP_ENUMERATION_ATTEMPTS) + .await + { + warn!("A2DP profile still missing, restarting WirePlumber as a last resort"); + if !self.restart_wire_plumber().await + || !self + .wait_for_a2dp_profile(Self::A2DP_ENUMERATION_ATTEMPTS) + .await + { + error!("A2DP profile unavailable, skipping profile activation"); return; } - } else { - error!("Could not restart WirePlumber, A2DP profile unavailable"); - return; } } + let Some(idx) = self.state.lock().await.device_index else { + error!("Device index not available for activating profile."); + return; + }; + + // Leave an already-active A2DP variant alone. Switching profiles + // recreates the PipeWire sink and stops the stream that triggered this + // activation, so players pause again right after the user pressed play. + if let Some(active) = self.get_active_profile(idx).await + && active.starts_with("a2dp") + { + debug!("A2DP profile {} already active, leaving it unchanged", active); + return; + } + let preferred_profile = self.get_preferred_a2dp_profile().await; if preferred_profile.is_empty() { error!("No suitable A2DP profile found"); return; } - let device_index = self.state.lock().await.device_index; - if let Some(idx) = device_index { - info!("Activating A2DP profile for AirPods: {}", preferred_profile); - let profile_name = preferred_profile.clone(); - let success = - tokio::task::spawn_blocking(move || set_card_profile_sync(idx, &profile_name)) - .await - .unwrap_or(false); - - if success { - info!("Successfully activated A2DP profile: {}", preferred_profile); - } else { - warn!("Failed to activate A2DP profile: {}", preferred_profile); - } + info!("Activating A2DP profile for AirPods: {}", preferred_profile); + let profile_name = preferred_profile.clone(); + let success = tokio::task::spawn_blocking(move || set_card_profile_sync(idx, &profile_name)) + .await + .unwrap_or(false); + + if success { + info!("Successfully activated A2DP profile: {}", preferred_profile); } else { - error!("Device index not available for activating profile."); + warn!("Failed to activate A2DP profile: {}", preferred_profile); } } @@ -594,25 +653,6 @@ impl MediaController { .unwrap_or(false) } - async fn restart_wire_plumber(&self) -> bool { - info!("Restarting WirePlumber to rediscover A2DP profiles"); - let result = Command::new("systemctl") - .args(["--user", "restart", "wireplumber"]) - .output(); - - match result { - Ok(output) if output.status.success() => { - info!("WirePlumber restarted successfully"); - tokio::time::sleep(Duration::from_secs(2)).await; - true - } - _ => { - error!("Failed to restart WirePlumber. Do you use wireplumber?"); - false - } - } - } - async fn get_audio_device_index(&self, mac: &str) -> Option { if mac.is_empty() { return None; @@ -920,10 +960,15 @@ fn get_card_info_list_sync() -> Vec { name: p.name.as_ref().map(|n| n.to_string()), }) .collect(); + let active_profile = item + .active_profile + .as_ref() + .and_then(|p| p.name.as_ref().map(|n| n.to_string())); cards.borrow_mut().push(OwnedCardInfo { index: item.index, proplist: item.proplist.clone(), profiles, + active_profile, }); } } From 7910df6a9bb605e74a4b71db41c878f7a181e419 Mon Sep 17 00:00:00 2001 From: injkgz Date: Fri, 4 Sep 2026 11:12:52 +0700 Subject: [PATCH 2/5] feat(linux): let the user pick the preferred A2DP codec The profile preference order was hardcoded as SBC-XQ, SBC, AAC, so AAC was never selected even though it is the codec AirPods use natively with Apple devices, and it is what PipeWire ranks highest for them. Add a "Preferred audio codec" setting (AAC, SBC-XQ, SBC) to the settings tab, stored as `preferred_codec` in app_settings.json and defaulting to AAC. The chosen codec is tried first and the remaining ones act as fallbacks, so a card that cannot negotiate the preferred codec still gets a working A2DP profile. --- linux-rust/src/media_controller.rs | 2 +- linux-rust/src/ui/window.rs | 93 +++++++++++++++++++++++++++++- linux-rust/src/utils.rs | 54 +++++++++++++++++ 3 files changed, 146 insertions(+), 3 deletions(-) diff --git a/linux-rust/src/media_controller.rs b/linux-rust/src/media_controller.rs index 03c293bcc..5a80a6d5f 100644 --- a/linux-rust/src/media_controller.rs +++ b/linux-rust/src/media_controller.rs @@ -623,7 +623,7 @@ impl MediaController { return cached_profile; } - for profile in ["a2dp-sink-sbc_xq", "a2dp-sink-sbc", "a2dp-sink"] { + for profile in crate::utils::load_preferred_codec().profile_order() { if self.is_profile_available(index, profile).await { info!("Selected best available A2DP profile: {}", profile); self.state.lock().await.cached_a2dp_profile = profile.to_string(); diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 4574b97ce..82f221060 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -9,14 +9,14 @@ use crate::devices::enums::{ use crate::ui::airpods::airpods_view; use crate::ui::messages::BluetoothUIMessage; use crate::ui::nothing::nothing_view; -use crate::utils::{MyTheme, get_app_settings_path, get_devices_path}; +use crate::utils::{MyTheme, PreferredCodec, get_app_settings_path, get_devices_path}; use bluer::{Address}; use iced::border::Radius; use iced::overlay::menu; use iced::widget::button::Style; use iced::widget::rule::FillMode; use iced::widget::{ - Space, button, column, combo_box, container, pane_grid, row, rule, scrollable, text, + Space, button, column, combo_box, container, pane_grid, pick_list, row, rule, scrollable, text, text_input, toggler }; use iced::{Background, Border, Center, Element, Font, Length, Padding, Size, Subscription, Task, Theme, daemon, window, Settings, Program}; @@ -76,6 +76,7 @@ pub struct App { selected_device_type: Option, tray_text_mode: bool, stem_control: bool, + preferred_codec: PreferredCodec, } pub struct BluetoothState { @@ -108,6 +109,7 @@ pub enum Message { StateChanged(String, DeviceState), TrayTextModeChanged(bool), // yes, I know I should add all settings to a struct, but I'm lazy StemControlChanged(bool), + PreferredCodecChanged(PreferredCodec), } #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -166,6 +168,11 @@ impl App { .and_then(|v| v.get("stem_control").cloned()) .and_then(|s| serde_json::from_value(s).ok()) .unwrap_or(false); + let preferred_codec = settings + .clone() + .and_then(|v| v.get("preferred_codec").cloned()) + .and_then(|c| serde_json::from_value(c).ok()) + .unwrap_or_default(); let bluetooth_state = BluetoothState::new(); @@ -217,6 +224,7 @@ impl App { device_managers, tray_text_mode, stem_control, + preferred_codec, }, Task::batch(vec![open_task, wait_task]), ) @@ -253,6 +261,7 @@ impl App { "theme": self.selected_theme, "tray_text_mode": self.tray_text_mode, "stem_control": self.stem_control, + "preferred_codec": self.preferred_codec, }); debug!( "Writing settings to {}: {}", @@ -631,6 +640,24 @@ impl App { "theme": self.selected_theme, "tray_text_mode": self.tray_text_mode, "stem_control": self.stem_control, + "preferred_codec": self.preferred_codec, + }); + debug!( + "Writing settings to {}: {}", + app_settings_path.to_str().unwrap(), + settings + ); + std::fs::write(app_settings_path, settings.to_string()).ok(); + Task::none() + } + Message::PreferredCodecChanged(codec) => { + self.preferred_codec = codec; + let app_settings_path = get_app_settings_path(); + let settings = serde_json::json!({ + "theme": self.selected_theme, + "tray_text_mode": self.tray_text_mode, + "stem_control": self.stem_control, + "preferred_codec": self.preferred_codec, }); debug!( "Writing settings to {}: {}", @@ -647,6 +674,7 @@ impl App { "theme": self.selected_theme, "tray_text_mode": self.tray_text_mode, "stem_control": self.stem_control, + "preferred_codec": self.preferred_codec, }); debug!( "Writing settings to {}: {}", @@ -927,6 +955,45 @@ impl App { } } Tab::Settings => { + let preferred_codec_picker = container( + row![ + column![ + text("Preferred audio codec").size(16), + text("Codec to activate for playback. The others are used as fallbacks if the chosen one is unavailable.").size(12).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + } + ).width(Length::Fill) + ].width(Length::Fill), + pick_list( + PreferredCodec::ALL, + Some(self.preferred_codec), + Message::PreferredCodecChanged, + ) + ] + .align_y(Center) + .spacing(12) + ) + .padding(Padding{ + top: 5.0, + bottom: 5.0, + left: 18.0, + right: 18.0, + }) + .style( + |theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + } + ) + .align_y(Center); + let tray_text_mode_toggle = container( row![ column![ @@ -1096,6 +1163,26 @@ impl App { ) .align_y(Center); + let audio_settings_col = column![ + container( + text("Audio").size(20).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().primary); + style + } + ) + ) + .padding(Padding{ + top: 0.0, + bottom: 0.0, + left: 18.0, + right: 18.0, + }), + preferred_codec_picker + ] + .spacing(12); + let controls_settings_col = column![ container( text("Controls").size(20).style( @@ -1123,6 +1210,8 @@ impl App { tray_text_mode_toggle, Space::new().height(Length::from(20)), controls_settings_col, + Space::new().height(Length::from(20)), + audio_settings_col, ] ) .padding(20) diff --git a/linux-rust/src/utils.rs b/linux-rust/src/utils.rs index 88ee466a8..697e933a6 100644 --- a/linux-rust/src/utils.rs +++ b/linux-rust/src/utils.rs @@ -51,6 +51,60 @@ pub fn get_app_settings_path() -> PathBuf { new_path } +/// Preferred A2DP codec. The chosen one is tried first, the rest act as fallbacks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum PreferredCodec { + #[default] + Aac, + SbcXq, + Sbc, +} + +impl PreferredCodec { + pub const ALL: [PreferredCodec; 3] = [Self::Aac, Self::SbcXq, Self::Sbc]; + + /// PulseAudio/PipeWire card profile name for this codec. + pub fn profile_name(self) -> &'static str { + match self { + Self::Aac => "a2dp-sink", + Self::SbcXq => "a2dp-sink-sbc_xq", + Self::Sbc => "a2dp-sink-sbc", + } + } + + /// Profiles to try, most preferred first. + pub fn profile_order(self) -> Vec<&'static str> { + std::iter::once(self.profile_name()) + .chain( + Self::ALL + .iter() + .filter(|c| **c != self) + .map(|c| c.profile_name()), + ) + .collect() + } +} + +impl std::fmt::Display for PreferredCodec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Aac => "AAC", + Self::SbcXq => "SBC-XQ", + Self::Sbc => "SBC", + }) + } +} + +/// Reads the preferred codec from the app settings file, falling back to AAC. +pub fn load_preferred_codec() -> PreferredCodec { + std::fs::read_to_string(get_app_settings_path()) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.get("preferred_codec").cloned()) + .and_then(|c| serde_json::from_value(c).ok()) + .unwrap_or_default() +} + fn e(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { let mut swapped_key = *key; swapped_key.reverse(); From 7754d0048c380974f9c88931668a968f994622f7 Mon Sep 17 00:00:00 2001 From: injkgz Date: Fri, 4 Sep 2026 16:17:49 +0700 Subject: [PATCH 3/5] fix(linux): report BLE battery to the window, not just the tray The LE monitor decodes left, right and case levels from the proximity advertisement but only pushed them into the tray. The window kept showing whatever AACP last reported, and over AACP the case reports itself as disconnected whenever the buds are outside it, so the case level was missing from the device view. Forward the decoded levels to the UI as an AACPEvent::BatteryInfo, the same event the AACP path already sends, skipping components the advertisement marks as unavailable (0xff). While here, move the "Auto-connect is disabled" log into the else branch. It sat inside the auto-connect branch, so it was printed right after a successful connection: Successfully connected to AirPods 34:0E:22:CE:A2:80 Auto-connect is disabled for 34:0E:22:CE:A2:80, not attempting to connect. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011cAE8Gb2vCuv9WLMh91R9g --- linux-rust/src/bluetooth/le.rs | 41 ++++++++++++++++++++++++++++++++-- linux-rust/src/main.rs | 3 ++- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/linux-rust/src/bluetooth/le.rs b/linux-rust/src/bluetooth/le.rs index 69918bf3a..c38e5f75b 100644 --- a/linux-rust/src/bluetooth/le.rs +++ b/linux-rust/src/bluetooth/le.rs @@ -1,5 +1,6 @@ -use crate::bluetooth::aacp::BatteryStatus; +use crate::bluetooth::aacp::{AACPEvent, BatteryComponent, BatteryInfo, BatteryStatus}; use crate::devices::enums::{DeviceData, DeviceInformation, DeviceType}; +use crate::ui::messages::BluetoothUIMessage; use crate::ui::tray::MyTray; use crate::utils::{ah, get_devices_path, get_preferences_path}; use aes::Aes128; @@ -15,6 +16,7 @@ use std::collections::{HashMap, HashSet}; use std::str::FromStr; use std::sync::Arc; use tokio::sync::Mutex; +use tokio::sync::mpsc::UnboundedSender; fn decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { let cipher = Aes128::new(&Array::from(*key)); @@ -46,7 +48,10 @@ fn verify_rpa(addr: &str, irk: &[u8; 16]) -> bool { hash == computed_hash } -pub async fn start_le_monitor(tray_handle: Option>) -> bluer::Result<()> { +pub async fn start_le_monitor( + tray_handle: Option>, + ui_tx: UnboundedSender, +) -> bluer::Result<()> { let session = Session::new().await?; let adapter = session.default_adapter().await?; adapter.set_powered(true).await?; @@ -143,6 +148,7 @@ pub async fn start_le_monitor(tray_handle: Option>) -> blue if matched_airpods_mac.is_some() { let mut events = dev.events().await?; let tray_handle_clone = tray_handle.clone(); + let ui_tx_clone = ui_tx.clone(); let connecting_macs_clone = Arc::clone(&connecting_macs); tokio::spawn(async move { while let Some(ev) = events.next().await { @@ -246,6 +252,7 @@ pub async fn start_le_monitor(tray_handle: Option>) -> blue ); } } + } else { info!( "Auto-connect is disabled for {}, not attempting to connect.", matched_airpods_mac.as_ref().unwrap() @@ -336,6 +343,36 @@ pub async fn start_le_monitor(tray_handle: Option>) -> blue .await; } + // The tray is not the only consumer: the window shows the + // case level too, and over AACP the case reports itself as + // disconnected whenever the buds are outside it. + if let Some(mac) = matched_airpods_mac.as_ref() { + let battery_info = [ + (BatteryComponent::Left, left_byte, left_battery, left_charging), + (BatteryComponent::Right, right_byte, right_battery, right_charging), + (BatteryComponent::Case, case_byte, case_battery, case_charging), + ] + .into_iter() + .filter(|(_, raw, _, _)| *raw != 0xff) + .map(|(component, _, level, charging)| BatteryInfo { + component, + level: level as u8, + status: if charging { + BatteryStatus::Charging + } else { + BatteryStatus::NotCharging + }, + }) + .collect::>(); + + if !battery_info.is_empty() { + let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent( + mac.clone(), + AACPEvent::BatteryInfo(battery_info), + )); + } + } + debug!( "Battery status: Left: {}, Right: {}, Case: {}, InEar: L:{} R:{}", if left_byte == 0xff { diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs index f43f575b2..b95610c69 100644 --- a/linux-rust/src/main.rs +++ b/linux-rust/src/main.rs @@ -154,9 +154,10 @@ async fn async_main( adapter.set_powered(true).await?; let le_tray_clone = tray_handle.clone(); + let le_ui_tx = ui_tx.clone(); tokio::spawn(async move { info!("Starting LE monitor..."); - if let Err(e) = start_le_monitor(le_tray_clone).await { + if let Err(e) = start_le_monitor(le_tray_clone, le_ui_tx).await { log::error!("LE monitor error: {}", e); } }); From b6273b899697e5b8091f68e1bb9547f867ca695f Mon Sep 17 00:00:00 2001 From: injkgz Date: Fri, 4 Sep 2026 16:35:05 +0700 Subject: [PATCH 4/5] fix(linux): explain Bluetooth startup failures instead of aborting async_main's result was unwrapped, so any Bluetooth error at startup panicked. With panic = "abort" in the release profile, a panic on the backend thread takes the whole process down, and the user sees a core dump with no explanation. Powering on a soft-blocked adapter is the common way to hit this: thread '' panicked at src/main.rs:98:18: called `Result::unwrap()` on an `Err` value: Error { kind: Failed, message: "Failed" } Log what actually went wrong for each startup step - D-Bus connection, adapter lookup, powering on - and exit with status 1 instead of aborting. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01A7MEAowWUpkKaDm8W2ws3C --- linux-rust/src/main.rs | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs index b95610c69..8b2d80897 100644 --- a/linux-rust/src/main.rs +++ b/linux-rust/src/main.rs @@ -87,15 +87,20 @@ fn main() -> iced::Result { // Run headless without UI info!("Running in headless mode (no GUI)"); let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async_main(ui_tx, device_managers)).unwrap(); + if let Err(e) = rt.block_on(async_main(ui_tx, device_managers)) { + log::error!("LibrePods could not start: {e}"); + std::process::exit(1); + } Ok(()) } else { // Run with UI let device_managers_clone = device_managers.clone(); std::thread::spawn(|| { let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async_main(ui_tx, device_managers_clone)) - .unwrap(); + if let Err(e) = rt.block_on(async_main(ui_tx, device_managers_clone)) { + log::error!("LibrePods could not start: {e}"); + std::process::exit(1); + } }); ui::window::start_ui(ui_rx, args.start_minimized, device_managers) @@ -149,9 +154,24 @@ async fn async_main( Some(handle) }; - let session = bluer::Session::new().await?; - let adapter = session.default_adapter().await?; - adapter.set_powered(true).await?; + let session = bluer::Session::new().await.inspect_err(|e| { + log::error!( + "Cannot talk to BlueZ over D-Bus: {e}. Is the bluetooth service running? \ + Check with `systemctl status bluetooth`." + ) + })?; + let adapter = session.default_adapter().await.inspect_err(|e| { + log::error!( + "No Bluetooth adapter available: {e}. Make sure an adapter is present \ + and not blocked - see `rfkill list bluetooth`." + ) + })?; + adapter.set_powered(true).await.inspect_err(|e| { + log::error!( + "Cannot power on the Bluetooth adapter: {e}. It is likely soft-blocked, \ + try `rfkill unblock bluetooth`." + ) + })?; let le_tray_clone = tray_handle.clone(); let le_ui_tx = ui_tx.clone(); From fbfad7b5160d532fbbfbb23b3801f39faedc3555 Mon Sep 17 00:00:00 2001 From: injkgz Date: Thu, 10 Sep 2026 14:15:40 +0700 Subject: [PATCH 5/5] fix(linux): claim an A2DP profile as soon as the buds connect PipeWire can leave a Bluetooth card on the "off" profile: the card shows up before BlueZ has negotiated the A2DP transport, and nothing revisits that choice afterwards. The buds then sit connected and silent, with no sink to play to, until something activates a profile. Profile activation only ran when playback started, so a user who connected their AirPods and did not immediately press play was left with working Bluetooth and no audio, which reads as the app being broken. Activate a profile right after the device is set up. The bounded wait for profile enumeration added earlier covers the case where the transport is still coming up. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UJd8jHVKWUebFZmNcSAWU3 --- linux-rust/src/devices/airpods.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/linux-rust/src/devices/airpods.rs b/linux-rust/src/devices/airpods.rs index f0e876cf0..5d42a88bd 100644 --- a/linux-rust/src/devices/airpods.rs +++ b/linux-rust/src/devices/airpods.rs @@ -142,6 +142,15 @@ impl AirPodsDevice { } }); + // PipeWire can leave the card on the "off" profile when it appears + // before its A2DP transport is ready, and nothing revisits that choice + // until playback starts - so freshly connected buds stay silent, and + // the microphone has no transport either. Claim a profile right away. + let mc_profile = media_controller.clone(); + tokio::spawn(async move { + mc_profile.lock().await.activate_a2dp_profile().await; + }); + let mc_listener = media_controller.lock().await; let aacp_manager_clone_listener = aacp_manager.clone(); mc_listener