1use fission_core::{
2 VolumeAdjustDirection, VolumeAdjustRequest, VolumeError, VolumeLevel, VolumeSetRequest,
3 VolumeStream, ADJUST_VOLUME_LEVEL, GET_VOLUME_LEVEL, SET_VOLUME_LEVEL,
4};
5use fission_shell::async_host::AsyncRegistry;
6use std::process::Command;
7use std::sync::{Arc, Mutex};
8
9pub trait VolumeHost: Send + Sync + 'static {
11 fn get_level(&self, stream: VolumeStream) -> Result<VolumeLevel, VolumeError>;
13 fn set_level(&self, request: VolumeSetRequest) -> Result<VolumeLevel, VolumeError>;
15 fn adjust_level(&self, request: VolumeAdjustRequest) -> Result<VolumeLevel, VolumeError>;
17}
18
19#[derive(Debug, Default)]
20pub struct UnsupportedVolumeHost;
21
22pub(crate) fn native_volume_host() -> impl VolumeHost {
23 if cfg!(any(target_os = "macos", target_os = "linux")) {
24 NativeVolumeHost
25 } else {
26 NativeVolumeHost
27 }
28}
29
30impl VolumeHost for UnsupportedVolumeHost {
31 fn get_level(&self, _stream: VolumeStream) -> Result<VolumeLevel, VolumeError> {
32 Err(VolumeError::unsupported("get_level"))
33 }
34
35 fn set_level(&self, _request: VolumeSetRequest) -> Result<VolumeLevel, VolumeError> {
36 Err(VolumeError::unsupported("set_level"))
37 }
38
39 fn adjust_level(&self, _request: VolumeAdjustRequest) -> Result<VolumeLevel, VolumeError> {
40 Err(VolumeError::unsupported("adjust_level"))
41 }
42}
43
44#[derive(Debug, Default)]
45pub struct NativeVolumeHost;
46
47impl NativeVolumeHost {
48 fn get_system_level(&self, stream: VolumeStream) -> Result<VolumeLevel, VolumeError> {
49 if cfg!(target_os = "macos") {
50 let output = Command::new("osascript")
51 .args(["-e", "output volume of (get volume settings)"])
52 .output()
53 .map_err(volume_command_error)?;
54 if !output.status.success() {
55 return Err(volume_status_error(output));
56 }
57 let level = String::from_utf8_lossy(&output.stdout)
58 .trim()
59 .parse::<u8>()
60 .map_err(|error| VolumeError::new("parse_error", error.to_string()))?
61 .min(100);
62 return Ok(VolumeLevel {
63 stream,
64 level,
65 muted: false,
66 });
67 }
68
69 if cfg!(target_os = "linux") && command_exists("pactl") {
70 let output = Command::new("pactl")
71 .args(["get-sink-volume", "@DEFAULT_SINK@"])
72 .output()
73 .map_err(volume_command_error)?;
74 if !output.status.success() {
75 return Err(volume_status_error(output));
76 }
77 let text = String::from_utf8_lossy(&output.stdout);
78 let level = parse_pactl_percent(&text)
79 .ok_or_else(|| VolumeError::new("parse_error", "failed to parse pactl volume"))?;
80 let muted = self.linux_muted().unwrap_or(false);
81 return Ok(VolumeLevel {
82 stream,
83 level,
84 muted,
85 });
86 }
87
88 Err(VolumeError::unsupported("get_level"))
89 }
90
91 fn set_system_level(&self, request: VolumeSetRequest) -> Result<VolumeLevel, VolumeError> {
92 let level = request.level.min(100);
93 if cfg!(target_os = "macos") {
94 Command::new("osascript")
95 .args(["-e", &format!("set volume output volume {level}")])
96 .status()
97 .map_err(volume_command_error)?;
98 if let Some(muted) = request.muted {
99 Command::new("osascript")
100 .args([
101 "-e",
102 if muted {
103 "set volume output muted true"
104 } else {
105 "set volume output muted false"
106 },
107 ])
108 .status()
109 .map_err(volume_command_error)?;
110 }
111 return self.get_system_level(request.stream);
112 }
113
114 if cfg!(target_os = "linux") && command_exists("pactl") {
115 Command::new("pactl")
116 .args(["set-sink-volume", "@DEFAULT_SINK@", &format!("{level}%")])
117 .status()
118 .map_err(volume_command_error)?;
119 if let Some(muted) = request.muted {
120 Command::new("pactl")
121 .args([
122 "set-sink-mute",
123 "@DEFAULT_SINK@",
124 if muted { "1" } else { "0" },
125 ])
126 .status()
127 .map_err(volume_command_error)?;
128 }
129 return self.get_system_level(request.stream);
130 }
131
132 Err(VolumeError::unsupported("set_level"))
133 }
134
135 fn linux_muted(&self) -> Result<bool, VolumeError> {
136 let output = Command::new("pactl")
137 .args(["get-sink-mute", "@DEFAULT_SINK@"])
138 .output()
139 .map_err(volume_command_error)?;
140 if !output.status.success() {
141 return Err(volume_status_error(output));
142 }
143 let text = String::from_utf8_lossy(&output.stdout);
144 Ok(text.contains("yes"))
145 }
146}
147
148impl VolumeHost for NativeVolumeHost {
149 fn get_level(&self, stream: VolumeStream) -> Result<VolumeLevel, VolumeError> {
150 self.get_system_level(stream)
151 }
152
153 fn set_level(&self, request: VolumeSetRequest) -> Result<VolumeLevel, VolumeError> {
154 self.set_system_level(request)
155 }
156
157 fn adjust_level(&self, request: VolumeAdjustRequest) -> Result<VolumeLevel, VolumeError> {
158 let current = self.get_level(request.stream)?;
159 let level = match request.direction {
160 VolumeAdjustDirection::Up => current.level.saturating_add(request.step).min(100),
161 VolumeAdjustDirection::Down => current.level.saturating_sub(request.step),
162 };
163 self.set_level(VolumeSetRequest {
164 stream: request.stream,
165 level,
166 muted: None,
167 })
168 }
169}
170
171#[derive(Debug)]
172pub struct MemoryVolumeHost {
173 level: Arc<Mutex<VolumeLevel>>,
174}
175
176impl Default for MemoryVolumeHost {
177 fn default() -> Self {
178 Self {
179 level: Arc::new(Mutex::new(VolumeLevel {
180 stream: VolumeStream::Media,
181 level: 50,
182 muted: false,
183 })),
184 }
185 }
186}
187
188impl MemoryVolumeHost {
189 pub fn current(&self) -> VolumeLevel {
190 self.level.lock().unwrap().clone()
191 }
192}
193
194impl VolumeHost for MemoryVolumeHost {
195 fn get_level(&self, stream: VolumeStream) -> Result<VolumeLevel, VolumeError> {
196 let mut level = self.level.lock().unwrap().clone();
197 level.stream = stream;
198 Ok(level)
199 }
200
201 fn set_level(&self, request: VolumeSetRequest) -> Result<VolumeLevel, VolumeError> {
202 let mut level = self.level.lock().unwrap();
203 level.stream = request.stream;
204 level.level = request.level.min(100);
205 if let Some(muted) = request.muted {
206 level.muted = muted;
207 }
208 Ok(level.clone())
209 }
210
211 fn adjust_level(&self, request: VolumeAdjustRequest) -> Result<VolumeLevel, VolumeError> {
212 let mut level = self.level.lock().unwrap();
213 level.stream = request.stream;
214 level.level = match request.direction {
215 VolumeAdjustDirection::Up => level.level.saturating_add(request.step).min(100),
216 VolumeAdjustDirection::Down => level.level.saturating_sub(request.step),
217 };
218 Ok(level.clone())
219 }
220}
221
222fn command_exists(name: &str) -> bool {
223 std::env::var_os("PATH")
224 .and_then(|paths| {
225 std::env::split_paths(&paths)
226 .map(|path| path.join(name))
227 .find(|path| path.is_file())
228 })
229 .is_some()
230}
231
232fn parse_pactl_percent(text: &str) -> Option<u8> {
233 text.split_whitespace()
234 .find_map(|part| part.strip_suffix('%'))
235 .and_then(|number| number.parse::<u16>().ok())
236 .map(|level| level.min(100) as u8)
237}
238
239fn volume_command_error(error: std::io::Error) -> VolumeError {
240 VolumeError::new("host_error", error.to_string())
241}
242
243fn volume_status_error(output: std::process::Output) -> VolumeError {
244 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
245 VolumeError::new(
246 "host_error",
247 if stderr.is_empty() {
248 format!("volume command exited with {}", output.status)
249 } else {
250 stderr
251 },
252 )
253}
254
255pub(crate) fn register_volume_capabilities(
256 async_registry: &mut AsyncRegistry,
257 host: Arc<dyn VolumeHost>,
258) {
259 let get_host = host.clone();
260 async_registry.register_operation_capability(GET_VOLUME_LEVEL, move |request, _| {
261 let host = get_host.clone();
262 async move { host.get_level(request) }
263 });
264
265 let set_host = host.clone();
266 async_registry.register_operation_capability(SET_VOLUME_LEVEL, move |request, _| {
267 let host = set_host.clone();
268 async move { host.set_level(request) }
269 });
270
271 async_registry.register_operation_capability(ADJUST_VOLUME_LEVEL, move |request, _| {
272 let host = host.clone();
273 async move { host.adjust_level(request) }
274 });
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[test]
282 fn unsupported_host_reports_errors() {
283 let host = UnsupportedVolumeHost;
284 assert!(host.get_level(VolumeStream::Media).is_err());
285 }
286
287 #[test]
288 fn memory_host_sets_and_adjusts_volume() {
289 let host = MemoryVolumeHost::default();
290 let set = host
291 .set_level(VolumeSetRequest {
292 stream: VolumeStream::Media,
293 level: 80,
294 muted: Some(false),
295 })
296 .unwrap();
297 assert_eq!(set.level, 80);
298
299 let adjusted = host
300 .adjust_level(VolumeAdjustRequest {
301 stream: VolumeStream::Media,
302 direction: VolumeAdjustDirection::Down,
303 step: 15,
304 })
305 .unwrap();
306 assert_eq!(adjusted.level, 65);
307 }
308
309 #[test]
310 fn pactl_volume_parser_extracts_first_percentage() {
311 let output =
312 "Volume: front-left: 32768 / 50% / -18.06 dB, front-right: 32768 / 50% / -18.06 dB";
313 assert_eq!(parse_pactl_percent(output), Some(50));
314 assert_eq!(parse_pactl_percent("no percentage"), None);
315 }
316
317 #[test]
318 fn native_volume_reports_unsupported_when_host_has_no_mixer() {
319 if cfg!(target_os = "macos") || (cfg!(target_os = "linux") && command_exists("pactl")) {
320 return;
321 }
322
323 let host = NativeVolumeHost;
324 assert_eq!(
325 host.get_level(VolumeStream::Media).unwrap_err().code,
326 "unsupported"
327 );
328 }
329}