volumecontrol_macos/
lib.rs1#![deny(missing_docs)]
40
41mod internal;
42
43use std::fmt;
44
45use volumecontrol_core::{AudioDevice as AudioDeviceTrait, AudioError, DeviceInfo};
46
47#[derive(Debug)]
55pub struct AudioDevice {
56 id: String,
58 name: String,
60}
61
62impl fmt::Display for AudioDevice {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 write!(f, "{} ({})", self.name, self.id)
66 }
67}
68
69#[cfg(feature = "coreaudio")]
70impl AudioDevice {
71 fn from_raw_id(raw_id: internal::AudioObjectID) -> Result<Self, AudioError> {
73 let name = internal::get_device_name(raw_id)?;
74 Ok(Self {
75 id: raw_id.to_string(),
76 name,
77 })
78 }
79}
80
81impl AudioDeviceTrait for AudioDevice {
82 fn from_default() -> Result<Self, AudioError> {
83 #[cfg(feature = "coreaudio")]
84 {
85 let raw_id = internal::get_default_device_id()?;
86 Self::from_raw_id(raw_id)
87 }
88 #[cfg(not(feature = "coreaudio"))]
89 Err(AudioError::Unsupported)
90 }
91
92 fn from_id(id: &str) -> Result<Self, AudioError> {
93 #[cfg(feature = "coreaudio")]
94 {
95 let raw_id: internal::AudioObjectID =
99 id.parse().map_err(|_| AudioError::DeviceNotFound)?;
100 let ids = internal::list_device_ids()?;
102 if !ids.contains(&raw_id) {
103 return Err(AudioError::DeviceNotFound);
104 }
105 Self::from_raw_id(raw_id)
106 }
107 #[cfg(not(feature = "coreaudio"))]
108 {
109 let _ = id;
110 Err(AudioError::Unsupported)
111 }
112 }
113
114 fn from_name(name: &str) -> Result<Self, AudioError> {
115 #[cfg(feature = "coreaudio")]
116 {
117 let name_lower = name.to_lowercase();
121 for raw_id in internal::list_device_ids()? {
122 let device_name = internal::get_device_name(raw_id)?;
123 if device_name.to_lowercase().contains(&name_lower) {
124 return Self::from_raw_id(raw_id);
125 }
126 }
127 Err(AudioError::DeviceNotFound)
128 }
129 #[cfg(not(feature = "coreaudio"))]
130 {
131 let _ = name;
132 Err(AudioError::Unsupported)
133 }
134 }
135
136 fn list() -> Result<Vec<DeviceInfo>, AudioError> {
137 #[cfg(feature = "coreaudio")]
138 {
139 let ids = internal::list_device_ids()?;
140 let mut devices = Vec::with_capacity(ids.len());
141 for raw_id in ids {
142 let name = internal::get_device_name(raw_id)?;
143 devices.push(DeviceInfo {
144 id: raw_id.to_string(),
145 name,
146 });
147 }
148 Ok(devices)
149 }
150 #[cfg(not(feature = "coreaudio"))]
151 Err(AudioError::Unsupported)
152 }
153
154 fn get_vol(&self) -> Result<u8, AudioError> {
155 #[cfg(feature = "coreaudio")]
156 {
157 let raw_id: internal::AudioObjectID =
158 self.id.parse().map_err(|_| AudioError::DeviceNotFound)?;
159 internal::get_volume(raw_id)
160 }
161 #[cfg(not(feature = "coreaudio"))]
162 Err(AudioError::Unsupported)
163 }
164
165 fn set_vol(&self, vol: u8) -> Result<(), AudioError> {
166 #[cfg(feature = "coreaudio")]
167 {
168 let raw_id: internal::AudioObjectID =
169 self.id.parse().map_err(|_| AudioError::DeviceNotFound)?;
170 internal::set_volume(raw_id, vol)
171 }
172 #[cfg(not(feature = "coreaudio"))]
173 {
174 let _ = vol;
175 Err(AudioError::Unsupported)
176 }
177 }
178
179 fn is_mute(&self) -> Result<bool, AudioError> {
180 #[cfg(feature = "coreaudio")]
181 {
182 let raw_id: internal::AudioObjectID =
183 self.id.parse().map_err(|_| AudioError::DeviceNotFound)?;
184 internal::get_mute(raw_id)
185 }
186 #[cfg(not(feature = "coreaudio"))]
187 Err(AudioError::Unsupported)
188 }
189
190 fn set_mute(&self, muted: bool) -> Result<(), AudioError> {
191 #[cfg(feature = "coreaudio")]
192 {
193 let raw_id: internal::AudioObjectID =
194 self.id.parse().map_err(|_| AudioError::DeviceNotFound)?;
195 internal::set_mute(raw_id, muted)
196 }
197 #[cfg(not(feature = "coreaudio"))]
198 {
199 let _ = muted;
200 Err(AudioError::Unsupported)
201 }
202 }
203
204 fn id(&self) -> &str {
205 &self.id
206 }
207
208 fn name(&self) -> &str {
209 &self.name
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use volumecontrol_core::AudioDevice as AudioDeviceTrait;
217
218 #[test]
220 fn display_format_is_name_paren_id() {
221 let device = AudioDevice {
222 id: "73".to_string(),
223 name: "MacBook Pro Speakers".to_string(),
224 };
225 assert_eq!(device.to_string(), "MacBook Pro Speakers (73)");
226 }
227
228 #[cfg(not(feature = "coreaudio"))]
234 #[test]
235 fn default_returns_unsupported_without_feature() {
236 let result = AudioDevice::from_default();
237 assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
238 }
239
240 #[cfg(not(feature = "coreaudio"))]
241 #[test]
242 fn from_id_returns_unsupported_without_feature() {
243 let result = AudioDevice::from_id("test-id");
244 assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
245 }
246
247 #[cfg(not(feature = "coreaudio"))]
248 #[test]
249 fn from_name_returns_unsupported_without_feature() {
250 let result = AudioDevice::from_name("test-name");
251 assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
252 }
253
254 #[cfg(not(feature = "coreaudio"))]
255 #[test]
256 fn list_returns_unsupported_without_feature() {
257 let result = AudioDevice::list();
258 assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
259 }
260
261 #[cfg(all(feature = "coreaudio", target_os = "macos"))]
266 #[test]
267 fn default_returns_ok() {
268 let device = AudioDevice::from_default();
269 assert!(device.is_ok(), "expected Ok, got {device:?}");
270 }
271
272 #[cfg(all(feature = "coreaudio", target_os = "macos"))]
273 #[test]
274 fn list_returns_nonempty() {
275 let devices = AudioDevice::list().expect("list()");
276 assert!(
277 !devices.is_empty(),
278 "expected at least one audio device from list()"
279 );
280 for info in &devices {
282 assert!(!info.id.is_empty(), "device id must not be empty");
283 assert!(!info.name.is_empty(), "device name must not be empty");
284 }
285 }
286
287 #[cfg(all(feature = "coreaudio", target_os = "macos"))]
288 #[test]
289 fn from_id_valid_id_returns_ok() {
290 let default_device = AudioDevice::from_default().expect("from_default()");
292 let found = AudioDevice::from_id(default_device.id());
293 assert!(found.is_ok(), "from_id with valid id should succeed");
294 assert_eq!(found.unwrap().id(), default_device.id());
295 }
296
297 #[cfg(all(feature = "coreaudio", target_os = "macos"))]
298 #[test]
299 fn from_id_invalid_id_returns_not_found() {
300 let result = AudioDevice::from_id("not-a-number");
301 assert!(
302 matches!(result.unwrap_err(), AudioError::DeviceNotFound),
303 "non-numeric id should return DeviceNotFound"
304 );
305 }
306
307 #[cfg(all(feature = "coreaudio", target_os = "macos"))]
308 #[test]
309 fn from_name_partial_match_returns_ok() {
310 let default_device = AudioDevice::from_default().expect("from_default()");
313 let partial: String = default_device.name().chars().take(3).collect();
314 let found = AudioDevice::from_name(&partial);
315 assert!(
316 found.is_ok(),
317 "from_name with partial match '{partial}' should succeed"
318 );
319 }
320
321 #[cfg(all(feature = "coreaudio", target_os = "macos"))]
322 #[test]
323 fn from_name_case_insensitive_match_returns_ok() {
324 let default_device = AudioDevice::from_default().expect("from_default()");
327 let upper = default_device.name().to_uppercase();
328 let found = AudioDevice::from_name(&upper);
329 assert!(
330 found.is_ok(),
331 "from_name with uppercase query '{upper}' should succeed (case-insensitive)"
332 );
333 }
334
335 #[cfg(all(feature = "coreaudio", target_os = "macos"))]
336 #[test]
337 fn from_name_no_match_returns_not_found() {
338 let result = AudioDevice::from_name("\x00\x01\x02");
339 assert!(
340 matches!(result.unwrap_err(), AudioError::DeviceNotFound),
341 "unrecognised name should return DeviceNotFound"
342 );
343 }
344
345 #[cfg(all(feature = "coreaudio", target_os = "macos"))]
346 #[test]
347 fn get_vol_returns_valid_range() {
348 let device = AudioDevice::from_default().expect("from_default()");
349 let vol = device.get_vol().expect("get_vol()");
350 assert!(vol <= 100, "volume must be in 0..=100, got {vol}");
351 }
352
353 #[cfg(all(feature = "coreaudio", target_os = "macos"))]
354 #[test]
355 fn set_vol_roundtrip() {
356 let device = AudioDevice::from_default().expect("from_default()");
357 let original = device.get_vol().expect("get_vol()");
358 device.set_vol(original).expect("set_vol()");
359 let after = device.get_vol().expect("get_vol() after set");
360 assert!(
362 original.abs_diff(after) <= 1,
363 "volume changed unexpectedly: {original} -> {after}"
364 );
365 }
366
367 #[cfg(all(feature = "coreaudio", target_os = "macos"))]
368 #[test]
369 fn set_mute_roundtrip() {
370 let device = AudioDevice::from_default().expect("from_default()");
371 let original = device.is_mute().expect("is_mute()");
372 device.set_mute(original).expect("set_mute()");
373 let after = device.is_mute().expect("is_mute() after set");
374 assert_eq!(original, after, "mute state changed unexpectedly");
375 }
376}