use crate::error::{AppError, Result};
use std::process::Command;
use std::thread;
use std::time::Duration;
const MAX_RETRIES: u32 = 3;
const RETRY_DELAY_MS: u64 = 500;
#[derive(Debug, Clone)]
struct BluetoothDeviceInfo {
name: String,
#[allow(dead_code)]
address: String,
connected: bool,
is_keyboard: bool,
}
fn get_connected_devices() -> Result<Vec<BluetoothDeviceInfo>> {
let stdout = run_system_profiler_with_retry(MAX_RETRIES)?;
parse_bluetooth_json(&stdout)
}
fn run_system_profiler_with_retry(max_retries: u32) -> Result<Vec<u8>> {
let mut last_error = None;
let mut delay = Duration::from_millis(RETRY_DELAY_MS);
for attempt in 0..=max_retries {
match run_system_profiler() {
Ok(stdout) => return Ok(stdout),
Err(e) => {
last_error = Some(e);
if attempt < max_retries {
thread::sleep(delay);
delay *= 2; }
}
}
}
Err(last_error.unwrap_or_else(|| {
AppError::Bluetooth("Failed to get Bluetooth devices after retries".to_string())
}))
}
fn run_system_profiler() -> Result<Vec<u8>> {
let output = Command::new("system_profiler")
.args(["SPBluetoothDataType", "-json"])
.output()
.map_err(|e| AppError::Bluetooth(format!("Failed to run system_profiler: {}", e)))?;
if !output.status.success() {
return Err(AppError::Bluetooth(
"system_profiler command failed".to_string(),
));
}
Ok(output.stdout)
}
fn parse_bluetooth_json(json_bytes: &[u8]) -> Result<Vec<BluetoothDeviceInfo>> {
let json: serde_json::Value = serde_json::from_slice(json_bytes)
.map_err(|e| AppError::Bluetooth(format!("Failed to parse JSON: {}", e)))?;
let mut devices = Vec::new();
if let Some(bluetooth_data) = json.get("SPBluetoothDataType").and_then(|v| v.as_array()) {
for controller in bluetooth_data {
if let Some(connected) = controller
.get("device_connected")
.and_then(|v| v.as_array())
{
for device in connected {
if let Some(device_obj) = device.as_object() {
for (name, info) in device_obj {
let address = info
.get("device_address")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let is_keyboard = info
.get("device_minorType")
.and_then(|v| v.as_str())
.is_some_and(|t| t.to_ascii_lowercase().contains("keyboard"));
devices.push(BluetoothDeviceInfo {
name: name.clone(),
address,
connected: true,
is_keyboard,
});
}
}
}
}
if let Some(device_title) = controller.get("device_title").and_then(|v| v.as_array()) {
for section in device_title {
if let Some(section_devices) = section.get("_items").and_then(|v| v.as_array())
{
for device in section_devices {
let name = device
.get("_name")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let address = device
.get("device_address")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let connected = device
.get("device_connected")
.and_then(|v| v.as_str())
.is_some_and(|s| {
s == "attrib_Yes" || s.eq_ignore_ascii_case("yes")
});
let is_keyboard = device
.get("device_minorType")
.and_then(|v| v.as_str())
.is_some_and(|t| t.to_ascii_lowercase().contains("keyboard"));
devices.push(BluetoothDeviceInfo {
name,
address,
connected,
is_keyboard,
});
}
}
}
}
}
}
Ok(devices)
}
pub fn list_bluetooth_devices() -> Result<()> {
let devices = get_connected_devices()?;
if devices.is_empty() {
println!("No Bluetooth devices found.");
return Ok(());
}
println!("Bluetooth Devices:");
for device in devices.iter() {
let status = if device.connected {
"Connected"
} else {
"Paired"
};
let device_type = if device.is_keyboard {
"Keyboard"
} else {
"Other"
};
println!(
" Name: {}, Status: {}, Type: {}",
device.name, status, device_type
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_device_title_section() {
let json = br#"{
"SPBluetoothDataType": [
{
"device_title": [
{
"_items": [
{
"_name": "Magic Keyboard",
"device_address": "AA-BB-CC",
"device_connected": "attrib_Yes",
"device_minorType": "Keyboard"
}
]
}
]
}
]
}"#;
let devices = parse_bluetooth_json(json).expect("parse");
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].name, "Magic Keyboard");
assert!(devices[0].connected);
assert!(devices[0].is_keyboard);
}
#[test]
fn parses_empty_when_no_bluetooth_data() {
let devices = parse_bluetooth_json(b"{}").expect("parse");
assert!(devices.is_empty());
}
}