From fb7503eeb4f45eb1bd8a76ae4b9eb136e4f2dafc Mon Sep 17 00:00:00 2001 From: Albert Armea Date: Sun, 28 Dec 2025 09:30:54 -0500 Subject: [PATCH] Add volume policy --- config.example.toml | 12 + crates/shepherd-api/src/commands.rs | 25 ++ crates/shepherd-api/src/events.rs | 6 + crates/shepherd-api/src/types.rs | 64 ++++ crates/shepherd-config/src/policy.rs | 60 +++- crates/shepherd-config/src/schema.rs | 30 ++ crates/shepherd-config/src/validation.rs | 2 + crates/shepherd-core/src/engine.rs | 6 + crates/shepherd-host-api/src/lib.rs | 2 + crates/shepherd-host-api/src/volume.rs | 171 +++++++++++ crates/shepherd-host-linux/src/lib.rs | 3 + crates/shepherd-host-linux/src/volume.rs | 372 +++++++++++++++++++++++ crates/shepherd-hud/src/app.rs | 133 +++++++- crates/shepherd-hud/src/volume.rs | 234 ++++++++------ crates/shepherd-launcher-ui/src/state.rs | 3 + crates/shepherdd/src/main.rs | 254 +++++++++++++++- crates/shepherdd/tests/integration.rs | 2 + 17 files changed, 1272 insertions(+), 107 deletions(-) create mode 100644 crates/shepherd-host-api/src/volume.rs create mode 100644 crates/shepherd-host-linux/src/volume.rs diff --git a/config.example.toml b/config.example.toml index 32c53a8..6e42599 100644 --- a/config.example.toml +++ b/config.example.toml @@ -13,6 +13,14 @@ config_version = 1 # Set to 0 for unlimited (no time limit) default_max_run_seconds = 3600 +# Global volume restrictions (optional) +# These apply when no entry-specific restrictions are defined +[daemon.volume] +max_volume = 80 # Maximum volume percentage (0-100) +# min_volume = 20 # Minimum volume percentage (0-100) +allow_mute = true # Whether mute toggle is allowed +allow_change = true # Whether volume changes are allowed at all + # Default warning thresholds [[daemon.default_warnings]] seconds_before = 300 @@ -96,6 +104,10 @@ seconds_before = 30 severity = "critical" message = "30 seconds! Save NOW!" +# Entry-specific volume restrictions (overrides global) +[entries.volume] +max_volume = 60 # Limit volume during gaming sessions + # Example: Educational game [[entries]] id = "tuxmath" diff --git a/crates/shepherd-api/src/commands.rs b/crates/shepherd-api/src/commands.rs index 1c3d49f..906d40b 100644 --- a/crates/shepherd-api/src/commands.rs +++ b/crates/shepherd-api/src/commands.rs @@ -127,6 +127,26 @@ pub enum Command { /// Get health status GetHealth, + // Volume control commands + + /// Get current volume status + GetVolume, + + /// Set volume to a specific percentage + SetVolume { percent: u8 }, + + /// Increase volume by a step + VolumeUp { step: u8 }, + + /// Decrease volume by a step + VolumeDown { step: u8 }, + + /// Toggle mute state + ToggleMute, + + /// Set mute state explicitly + SetMute { muted: bool }, + // Admin commands /// Extend the current session (admin only) @@ -161,6 +181,11 @@ pub enum ResponsePayload { /// New deadline. None if session is unlimited (can't be extended). new_deadline: Option>, }, + Volume(crate::VolumeInfo), + VolumeSet, + VolumeDenied { + reason: String, + }, Pong, } diff --git a/crates/shepherd-api/src/events.rs b/crates/shepherd-api/src/events.rs index fa58dd8..e1ca83a 100644 --- a/crates/shepherd-api/src/events.rs +++ b/crates/shepherd-api/src/events.rs @@ -74,6 +74,12 @@ pub enum EventPayload { enabled: bool, }, + /// Volume status changed + VolumeChanged { + percent: u8, + muted: bool, + }, + /// Daemon is shutting down Shutdown, diff --git a/crates/shepherd-api/src/types.rs b/crates/shepherd-api/src/types.rs index 177ccdc..2dc9c8b 100644 --- a/crates/shepherd-api/src/types.rs +++ b/crates/shepherd-api/src/types.rs @@ -249,6 +249,70 @@ pub struct HealthStatus { pub store_ok: bool, } +/// Volume status information +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct VolumeInfo { + /// Volume percentage (0-100) + pub percent: u8, + /// Whether audio is muted + pub muted: bool, + /// Whether volume control is available + pub available: bool, + /// The detected sound backend (e.g., "pipewire", "pulseaudio", "alsa") + pub backend: Option, + /// Current restrictions on volume + pub restrictions: VolumeRestrictions, +} + +/// Volume restrictions that are currently in effect +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct VolumeRestrictions { + /// Maximum volume percentage allowed + pub max_volume: Option, + /// Minimum volume percentage allowed + pub min_volume: Option, + /// Whether mute toggle is allowed + pub allow_mute: bool, + /// Whether volume changes are allowed at all + pub allow_change: bool, +} + +impl VolumeRestrictions { + /// Create unrestricted volume settings + pub fn unrestricted() -> Self { + Self { + max_volume: None, + min_volume: None, + allow_mute: true, + allow_change: true, + } + } + + /// Clamp a volume value to the allowed range + pub fn clamp_volume(&self, percent: u8) -> u8 { + let min = self.min_volume.unwrap_or(0); + let max = self.max_volume.unwrap_or(100); + percent.clamp(min, max) + } +} + +impl VolumeInfo { + /// Get an icon name for the current volume status + pub fn icon_name(&self) -> &'static str { + if self.muted { + "audio-volume-muted-symbolic" + } else if self.percent == 0 { + "audio-volume-muted-symbolic" + } else if self.percent < 33 { + "audio-volume-low-symbolic" + } else if self.percent < 66 { + "audio-volume-medium-symbolic" + } else { + "audio-volume-high-symbolic" + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/shepherd-config/src/policy.rs b/crates/shepherd-config/src/policy.rs index 993969b..67787ad 100644 --- a/crates/shepherd-config/src/policy.rs +++ b/crates/shepherd-config/src/policy.rs @@ -1,6 +1,6 @@ //! Validated policy structures -use crate::schema::{RawConfig, RawDays, RawEntry, RawEntryKind, RawWarningThreshold}; +use crate::schema::{RawConfig, RawDays, RawEntry, RawEntryKind, RawVolumeConfig, RawWarningThreshold}; use crate::validation::{parse_days, parse_time}; use shepherd_api::{EntryKind, WarningSeverity, WarningThreshold}; use shepherd_util::{DaysOfWeek, EntryId, TimeWindow, WallClock}; @@ -22,6 +22,9 @@ pub struct Policy { /// Default max run duration. None means unlimited. pub default_max_run: Option, + + /// Global volume restrictions + pub volume: VolumePolicy, } impl Policy { @@ -41,10 +44,17 @@ impl Policy { .map(seconds_to_duration_or_unlimited) .unwrap_or(Some(Duration::from_secs(3600))); // 1 hour default + let global_volume = raw + .daemon + .volume + .as_ref() + .map(convert_volume_config) + .unwrap_or_default(); + let entries = raw .entries .into_iter() - .map(|e| Entry::from_raw(e, &default_warnings, default_max_run)) + .map(|e| Entry::from_raw(e, &default_warnings, default_max_run, &global_volume)) .collect(); Self { @@ -52,6 +62,7 @@ impl Policy { entries, default_warnings, default_max_run, + volume: global_volume, } } @@ -105,6 +116,7 @@ pub struct Entry { pub availability: AvailabilityPolicy, pub limits: LimitsPolicy, pub warnings: Vec, + pub volume: Option, pub disabled: bool, pub disabled_reason: Option, } @@ -114,6 +126,7 @@ impl Entry { raw: RawEntry, default_warnings: &[WarningThreshold], default_max_run: Option, + _global_volume: &VolumePolicy, ) -> Self { let kind = convert_entry_kind(raw.kind); let availability = raw @@ -132,6 +145,7 @@ impl Entry { .warnings .map(|w| w.into_iter().map(convert_warning).collect()) .unwrap_or_else(|| default_warnings.to_vec()); + let volume = raw.volume.as_ref().map(convert_volume_config); Self { id: EntryId::new(raw.id), @@ -141,6 +155,7 @@ impl Entry { availability, limits, warnings, + volume, disabled: raw.disabled, disabled_reason: raw.disabled_reason, } @@ -190,6 +205,38 @@ pub struct LimitsPolicy { pub cooldown: Option, } +/// Volume control policy +#[derive(Debug, Clone, Default)] +pub struct VolumePolicy { + /// Maximum volume percentage allowed (enforced by daemon) + pub max_volume: Option, + /// Minimum volume percentage allowed (enforced by daemon) + pub min_volume: Option, + /// Whether mute toggle is allowed + pub allow_mute: bool, + /// Whether volume changes are allowed at all + pub allow_change: bool, +} + +impl VolumePolicy { + /// Create unrestricted volume settings + pub fn unrestricted() -> Self { + Self { + max_volume: None, + min_volume: None, + allow_mute: true, + allow_change: true, + } + } + + /// Clamp a volume value to the allowed range + pub fn clamp_volume(&self, percent: u8) -> u8 { + let min = self.min_volume.unwrap_or(0); + let max = self.max_volume.unwrap_or(100); + percent.clamp(min, max) + } +} + // Conversion helpers fn convert_entry_kind(raw: RawEntryKind) -> EntryKind { @@ -213,6 +260,15 @@ fn convert_availability(raw: crate::schema::RawAvailability) -> AvailabilityPoli } } +fn convert_volume_config(raw: &RawVolumeConfig) -> VolumePolicy { + VolumePolicy { + max_volume: raw.max_volume, + min_volume: raw.min_volume, + allow_mute: raw.allow_mute, + allow_change: raw.allow_change, + } +} + fn convert_time_window(raw: crate::schema::RawTimeWindow) -> TimeWindow { let days_mask = parse_days(&raw.days).unwrap_or(0x7F); let (start_h, start_m) = parse_time(&raw.start).unwrap_or((0, 0)); diff --git a/crates/shepherd-config/src/schema.rs b/crates/shepherd-config/src/schema.rs index 468afca..809f753 100644 --- a/crates/shepherd-config/src/schema.rs +++ b/crates/shepherd-config/src/schema.rs @@ -36,6 +36,10 @@ pub struct RawDaemonConfig { /// Default max run duration pub default_max_run_seconds: Option, + + /// Global volume restrictions + #[serde(default)] + pub volume: Option, } /// Raw entry definition @@ -65,6 +69,10 @@ pub struct RawEntry { #[serde(default)] pub warnings: Option>, + /// Volume restrictions for this entry (overrides global) + #[serde(default)] + pub volume: Option, + /// Explicitly disabled #[serde(default)] pub disabled: bool, @@ -181,6 +189,28 @@ fn default_severity() -> String { "warn".to_string() } +/// Volume control configuration +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct RawVolumeConfig { + /// Maximum volume percentage allowed (0-100) + pub max_volume: Option, + + /// Minimum volume percentage allowed (0-100) + pub min_volume: Option, + + /// Whether mute toggle is allowed (default: true) + #[serde(default = "default_true")] + pub allow_mute: bool, + + /// Whether volume changes are allowed at all (default: true) + #[serde(default = "default_true")] + pub allow_change: bool, +} + +fn default_true() -> bool { + true +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/shepherd-config/src/validation.rs b/crates/shepherd-config/src/validation.rs index 9abbb32..6fbf83b 100644 --- a/crates/shepherd-config/src/validation.rs +++ b/crates/shepherd-config/src/validation.rs @@ -260,6 +260,7 @@ mod tests { availability: None, limits: None, warnings: None, + volume: None, disabled: false, disabled_reason: None, }, @@ -276,6 +277,7 @@ mod tests { availability: None, limits: None, warnings: None, + volume: None, disabled: false, disabled_reason: None, }, diff --git a/crates/shepherd-core/src/engine.rs b/crates/shepherd-core/src/engine.rs index fa42707..e8b91fd 100644 --- a/crates/shepherd-core/src/engine.rs +++ b/crates/shepherd-core/src/engine.rs @@ -574,11 +574,13 @@ mod tests { cooldown: None, }, warnings: vec![], + volume: None, disabled: false, disabled_reason: None, }], default_warnings: vec![], default_max_run: Some(Duration::from_secs(3600)), + volume: Default::default(), } } @@ -655,12 +657,14 @@ mod tests { severity: WarningSeverity::Warn, message_template: Some("1 minute left".into()), }], + volume: None, disabled: false, disabled_reason: None, }], daemon: Default::default(), default_warnings: vec![], default_max_run: Some(Duration::from_secs(3600)), + volume: Default::default(), }; let store = Arc::new(SqliteStore::in_memory().unwrap()); @@ -714,12 +718,14 @@ mod tests { cooldown: None, }, warnings: vec![], + volume: None, disabled: false, disabled_reason: None, }], daemon: Default::default(), default_warnings: vec![], default_max_run: Some(Duration::from_secs(3600)), + volume: Default::default(), }; let store = Arc::new(SqliteStore::in_memory().unwrap()); diff --git a/crates/shepherd-host-api/src/lib.rs b/crates/shepherd-host-api/src/lib.rs index a9c46b0..d71699c 100644 --- a/crates/shepherd-host-api/src/lib.rs +++ b/crates/shepherd-host-api/src/lib.rs @@ -7,8 +7,10 @@ mod capabilities; mod handle; mod mock; mod traits; +mod volume; pub use capabilities::*; pub use handle::*; pub use mock::*; pub use traits::*; +pub use volume::*; diff --git a/crates/shepherd-host-api/src/volume.rs b/crates/shepherd-host-api/src/volume.rs new file mode 100644 index 0000000..7983278 --- /dev/null +++ b/crates/shepherd-host-api/src/volume.rs @@ -0,0 +1,171 @@ +//! Volume control trait interfaces +//! +//! Defines the capability-based interface for volume control between +//! the daemon core and platform-specific implementations. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Errors from volume control operations +#[derive(Debug, Error)] +pub enum VolumeError { + #[error("Volume control not available: {0}")] + NotAvailable(String), + + #[error("Backend error: {0}")] + Backend(String), + + #[error("Volume out of range: {0}")] + OutOfRange(u8), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +pub type VolumeResult = Result; + +/// Volume status +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct VolumeStatus { + /// Volume percentage (0-100, can exceed if system allows) + pub percent: u8, + /// Whether audio is muted + pub muted: bool, +} + +impl VolumeStatus { + /// Get an icon name for the current volume status + pub fn icon_name(&self) -> &'static str { + if self.muted { + "audio-volume-muted-symbolic" + } else if self.percent == 0 { + "audio-volume-muted-symbolic" + } else if self.percent < 33 { + "audio-volume-low-symbolic" + } else if self.percent < 66 { + "audio-volume-medium-symbolic" + } else { + "audio-volume-high-symbolic" + } + } +} + +/// Volume capabilities +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct VolumeCapabilities { + /// Whether volume control is available + pub available: bool, + /// The detected sound backend (e.g., "pipewire", "pulseaudio", "alsa") + pub backend: Option, + /// Whether mute control is available + pub can_mute: bool, + /// Maximum volume percentage allowed (for systems that allow >100%) + pub max_volume: u8, +} + +/// Volume restrictions that can be enforced by policy +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct VolumeRestrictions { + /// Maximum volume percentage allowed (enforced by daemon) + pub max_volume: Option, + /// Minimum volume percentage allowed (enforced by daemon) + pub min_volume: Option, + /// Whether mute toggle is allowed + pub allow_mute: bool, + /// Whether volume changes are allowed at all + pub allow_change: bool, +} + +impl VolumeRestrictions { + /// Create unrestricted volume settings + pub fn unrestricted() -> Self { + Self { + max_volume: None, + min_volume: None, + allow_mute: true, + allow_change: true, + } + } + + /// Clamp a volume value to the allowed range + pub fn clamp_volume(&self, percent: u8) -> u8 { + let min = self.min_volume.unwrap_or(0); + let max = self.max_volume.unwrap_or(100); + percent.clamp(min, max) + } +} + +/// Volume controller trait - implemented by platform-specific adapters +#[async_trait] +pub trait VolumeController: Send + Sync { + /// Get the capabilities of this volume controller + fn capabilities(&self) -> &VolumeCapabilities; + + /// Get current volume status + async fn get_status(&self) -> VolumeResult; + + /// Set volume to a specific percentage + async fn set_volume(&self, percent: u8) -> VolumeResult<()>; + + /// Increase volume by a step + async fn volume_up(&self, step: u8) -> VolumeResult<()>; + + /// Decrease volume by a step + async fn volume_down(&self, step: u8) -> VolumeResult<()>; + + /// Toggle mute state + async fn toggle_mute(&self) -> VolumeResult<()>; + + /// Set mute state explicitly + async fn set_mute(&self, muted: bool) -> VolumeResult<()>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_volume_icon_names() { + let status = VolumeStatus { + percent: 0, + muted: false, + }; + assert_eq!(status.icon_name(), "audio-volume-muted-symbolic"); + + let status = VolumeStatus { + percent: 50, + muted: false, + }; + assert_eq!(status.icon_name(), "audio-volume-medium-symbolic"); + + let status = VolumeStatus { + percent: 100, + muted: true, + }; + assert_eq!(status.icon_name(), "audio-volume-muted-symbolic"); + } + + #[test] + fn test_restrictions_clamp() { + let restrictions = VolumeRestrictions { + max_volume: Some(80), + min_volume: Some(20), + allow_mute: true, + allow_change: true, + }; + + assert_eq!(restrictions.clamp_volume(50), 50); + assert_eq!(restrictions.clamp_volume(10), 20); + assert_eq!(restrictions.clamp_volume(90), 80); + } + + #[test] + fn test_unrestricted() { + let restrictions = VolumeRestrictions::unrestricted(); + assert_eq!(restrictions.clamp_volume(0), 0); + assert_eq!(restrictions.clamp_volume(100), 100); + assert!(restrictions.allow_mute); + assert!(restrictions.allow_change); + } +} diff --git a/crates/shepherd-host-linux/src/lib.rs b/crates/shepherd-host-linux/src/lib.rs index 0c6dfea..12b101c 100644 --- a/crates/shepherd-host-linux/src/lib.rs +++ b/crates/shepherd-host-linux/src/lib.rs @@ -5,9 +5,12 @@ //! - Graceful (SIGTERM) and forceful (SIGKILL) termination //! - Exit observation //! - stdout/stderr capture +//! - Volume control with auto-detection of sound systems mod adapter; mod process; +mod volume; pub use adapter::*; pub use process::*; +pub use volume::*; diff --git a/crates/shepherd-host-linux/src/volume.rs b/crates/shepherd-host-linux/src/volume.rs new file mode 100644 index 0000000..c83d5f6 --- /dev/null +++ b/crates/shepherd-host-linux/src/volume.rs @@ -0,0 +1,372 @@ +//! Linux volume control implementation +//! +//! Provides volume control with auto-detection of sound systems: +//! - PipeWire (via `wpctl`) +//! - PulseAudio (via `pactl`) +//! - ALSA (via `amixer`) + +use async_trait::async_trait; +use shepherd_host_api::{ + VolumeCapabilities, VolumeController, VolumeError, VolumeResult, VolumeStatus, +}; +use std::process::Command; +use tracing::{debug, info, warn}; + +/// Detected sound backend +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SoundBackend { + /// PipeWire with WirePlumber + PipeWire, + /// PulseAudio + PulseAudio, + /// ALSA (direct) + Alsa, +} + +impl SoundBackend { + /// Detect the best available sound backend + pub fn detect() -> Option { + // Try PipeWire first (modern systems) + if Self::is_pipewire_available() { + info!("Detected PipeWire sound backend"); + return Some(Self::PipeWire); + } + + // Try PulseAudio + if Self::is_pulseaudio_available() { + info!("Detected PulseAudio sound backend"); + return Some(Self::PulseAudio); + } + + // Try ALSA as fallback + if Self::is_alsa_available() { + info!("Detected ALSA sound backend"); + return Some(Self::Alsa); + } + + warn!("No sound backend detected"); + None + } + + fn is_pipewire_available() -> bool { + // Check if wpctl is available and can communicate with PipeWire + Command::new("wpctl") + .args(["status"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn is_pulseaudio_available() -> bool { + // Check if pactl is available and server is running + Command::new("pactl") + .args(["info"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn is_alsa_available() -> bool { + // Check if amixer is available + Command::new("amixer") + .args(["sget", "Master"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + pub fn name(&self) -> &'static str { + match self { + Self::PipeWire => "pipewire", + Self::PulseAudio => "pulseaudio", + Self::Alsa => "alsa", + } + } +} + +/// Linux volume controller with auto-detection +pub struct LinuxVolumeController { + capabilities: VolumeCapabilities, + backend: Option, +} + +impl LinuxVolumeController { + /// Create a new volume controller with auto-detection + pub fn new() -> Self { + let backend = SoundBackend::detect(); + + let capabilities = VolumeCapabilities { + available: backend.is_some(), + backend: backend.map(|b| b.name().to_string()), + can_mute: backend.is_some(), + max_volume: 100, + }; + + Self { + capabilities, + backend, + } + } + + /// Get volume status via PipeWire + fn get_status_pipewire() -> VolumeResult { + // Get volume: wpctl get-volume @DEFAULT_AUDIO_SINK@ + // Output: "Volume: 0.50" or "Volume: 0.50 [MUTED]" + let output = Command::new("wpctl") + .args(["get-volume", "@DEFAULT_AUDIO_SINK@"]) + .output() + .map_err(|e| VolumeError::Backend(e.to_string()))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + debug!("wpctl get-volume output: {}", stdout.trim()); + + let muted = stdout.contains("[MUTED]"); + + // Parse "Volume: 0.50" -> 50% + let percent = stdout + .split(':') + .nth(1) + .and_then(|s| s.split_whitespace().next()) + .and_then(|s| s.parse::().ok()) + .map(|v| (v * 100.0).round() as u8) + .unwrap_or(0); + + Ok(VolumeStatus { percent, muted }) + } + + /// Get volume status via PulseAudio + fn get_status_pulseaudio() -> VolumeResult { + let mut status = VolumeStatus::default(); + + // Get default sink info + if let Ok(output) = Command::new("pactl") + .args(["get-sink-volume", "@DEFAULT_SINK@"]) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + debug!("pactl get-sink-volume output: {}", stdout.trim()); + + // Output: "Volume: front-left: 65536 / 100% / -0.00 dB, front-right: ..." + if let Some(percent_str) = stdout.split('/').nth(1) { + if let Ok(percent) = percent_str.trim().trim_end_matches('%').parse::() { + status.percent = percent; + } + } + } + + // Check mute status + if let Ok(output) = Command::new("pactl") + .args(["get-sink-mute", "@DEFAULT_SINK@"]) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + debug!("pactl get-sink-mute output: {}", stdout.trim()); + status.muted = stdout.contains("yes"); + } + + Ok(status) + } + + /// Get volume status via ALSA + fn get_status_alsa() -> VolumeResult { + // amixer sget Master + // Output includes: "Front Left: Playback 65536 [100%] [on]" + let output = Command::new("amixer") + .args(["sget", "Master"]) + .output() + .map_err(|e| VolumeError::Backend(e.to_string()))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + debug!("amixer sget Master output: {}", stdout); + + let mut status = VolumeStatus::default(); + + for line in stdout.lines() { + if line.contains("Playback") && line.contains('%') { + // Extract percentage: [100%] + if let Some(start) = line.find('[') { + if let Some(end) = line[start..].find('%') { + if let Ok(percent) = line[start + 1..start + end].parse::() { + status.percent = percent; + } + } + } + // Check mute status: [on] or [off] + status.muted = line.contains("[off]"); + break; + } + } + + Ok(status) + } + + /// Set volume via PipeWire + fn set_volume_pipewire(percent: u8) -> VolumeResult<()> { + let volume = format!("{}%", percent); + Command::new("wpctl") + .args(["set-volume", "@DEFAULT_AUDIO_SINK@", &volume]) + .status() + .map_err(|e| VolumeError::Backend(e.to_string()))?; + Ok(()) + } + + /// Set volume via PulseAudio + fn set_volume_pulseaudio(percent: u8) -> VolumeResult<()> { + Command::new("pactl") + .args(["set-sink-volume", "@DEFAULT_SINK@", &format!("{}%", percent)]) + .status() + .map_err(|e| VolumeError::Backend(e.to_string()))?; + Ok(()) + } + + /// Set volume via ALSA + fn set_volume_alsa(percent: u8) -> VolumeResult<()> { + Command::new("amixer") + .args(["sset", "Master", &format!("{}%", percent)]) + .status() + .map_err(|e| VolumeError::Backend(e.to_string()))?; + Ok(()) + } + + /// Toggle mute via PipeWire + fn toggle_mute_pipewire() -> VolumeResult<()> { + Command::new("wpctl") + .args(["set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"]) + .status() + .map_err(|e| VolumeError::Backend(e.to_string()))?; + Ok(()) + } + + /// Toggle mute via PulseAudio + fn toggle_mute_pulseaudio() -> VolumeResult<()> { + Command::new("pactl") + .args(["set-sink-mute", "@DEFAULT_SINK@", "toggle"]) + .status() + .map_err(|e| VolumeError::Backend(e.to_string()))?; + Ok(()) + } + + /// Toggle mute via ALSA + fn toggle_mute_alsa() -> VolumeResult<()> { + Command::new("amixer") + .args(["sset", "Master", "toggle"]) + .status() + .map_err(|e| VolumeError::Backend(e.to_string()))?; + Ok(()) + } + + /// Set mute state via PipeWire + fn set_mute_pipewire(muted: bool) -> VolumeResult<()> { + let state = if muted { "1" } else { "0" }; + Command::new("wpctl") + .args(["set-mute", "@DEFAULT_AUDIO_SINK@", state]) + .status() + .map_err(|e| VolumeError::Backend(e.to_string()))?; + Ok(()) + } + + /// Set mute state via PulseAudio + fn set_mute_pulseaudio(muted: bool) -> VolumeResult<()> { + let state = if muted { "1" } else { "0" }; + Command::new("pactl") + .args(["set-sink-mute", "@DEFAULT_SINK@", state]) + .status() + .map_err(|e| VolumeError::Backend(e.to_string()))?; + Ok(()) + } + + /// Set mute state via ALSA + fn set_mute_alsa(muted: bool) -> VolumeResult<()> { + let state = if muted { "mute" } else { "unmute" }; + Command::new("amixer") + .args(["sset", "Master", state]) + .status() + .map_err(|e| VolumeError::Backend(e.to_string()))?; + Ok(()) + } +} + +impl Default for LinuxVolumeController { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl VolumeController for LinuxVolumeController { + fn capabilities(&self) -> &VolumeCapabilities { + &self.capabilities + } + + async fn get_status(&self) -> VolumeResult { + match self.backend { + Some(SoundBackend::PipeWire) => Self::get_status_pipewire(), + Some(SoundBackend::PulseAudio) => Self::get_status_pulseaudio(), + Some(SoundBackend::Alsa) => Self::get_status_alsa(), + None => Err(VolumeError::NotAvailable( + "No sound backend available".into(), + )), + } + } + + async fn set_volume(&self, percent: u8) -> VolumeResult<()> { + if percent > self.capabilities.max_volume { + return Err(VolumeError::OutOfRange(percent)); + } + + match self.backend { + Some(SoundBackend::PipeWire) => Self::set_volume_pipewire(percent), + Some(SoundBackend::PulseAudio) => Self::set_volume_pulseaudio(percent), + Some(SoundBackend::Alsa) => Self::set_volume_alsa(percent), + None => Err(VolumeError::NotAvailable( + "No sound backend available".into(), + )), + } + } + + async fn volume_up(&self, step: u8) -> VolumeResult<()> { + let current = self.get_status().await?; + let new_volume = current.percent.saturating_add(step).min(self.capabilities.max_volume); + self.set_volume(new_volume).await + } + + async fn volume_down(&self, step: u8) -> VolumeResult<()> { + let current = self.get_status().await?; + let new_volume = current.percent.saturating_sub(step); + self.set_volume(new_volume).await + } + + async fn toggle_mute(&self) -> VolumeResult<()> { + match self.backend { + Some(SoundBackend::PipeWire) => Self::toggle_mute_pipewire(), + Some(SoundBackend::PulseAudio) => Self::toggle_mute_pulseaudio(), + Some(SoundBackend::Alsa) => Self::toggle_mute_alsa(), + None => Err(VolumeError::NotAvailable( + "No sound backend available".into(), + )), + } + } + + async fn set_mute(&self, muted: bool) -> VolumeResult<()> { + match self.backend { + Some(SoundBackend::PipeWire) => Self::set_mute_pipewire(muted), + Some(SoundBackend::PulseAudio) => Self::set_mute_pulseaudio(muted), + Some(SoundBackend::Alsa) => Self::set_mute_alsa(muted), + None => Err(VolumeError::NotAvailable( + "No sound backend available".into(), + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_backend_name() { + assert_eq!(SoundBackend::PipeWire.name(), "pipewire"); + assert_eq!(SoundBackend::PulseAudio.name(), "pulseaudio"); + assert_eq!(SoundBackend::Alsa.name(), "alsa"); + } +} diff --git a/crates/shepherd-hud/src/app.rs b/crates/shepherd-hud/src/app.rs index 91d71b6..eb1522b 100644 --- a/crates/shepherd-hud/src/app.rs +++ b/crates/shepherd-hud/src/app.rs @@ -6,7 +6,6 @@ use crate::battery::BatteryStatus; use crate::state::{SessionState, SharedState}; use crate::time_display::TimeDisplay; -use crate::volume::VolumeStatus; use gtk4::glib; use gtk4::prelude::*; use gtk4_layer_shell::{Edge, Layer, LayerShell}; @@ -181,18 +180,71 @@ fn build_hud_content(state: SharedState) -> gtk4::Box { .halign(gtk4::Align::End) .build(); - // Volume indicator + // Volume control with slider + let volume_box = gtk4::Box::builder() + .orientation(gtk4::Orientation::Horizontal) + .spacing(4) + .build(); + volume_box.add_css_class("volume-control"); + + // Mute button let volume_button = gtk4::Button::builder() .icon_name("audio-volume-medium-symbolic") .has_frame(false) + .tooltip_text("Toggle mute") .build(); volume_button.add_css_class("indicator-button"); volume_button.connect_clicked(|_| { - if let Err(e) = VolumeStatus::toggle_mute() { + if let Err(e) = crate::volume::toggle_mute() { tracing::error!("Failed to toggle mute: {}", e); } }); - right_box.append(&volume_button); + volume_box.append(&volume_button); + + // Volume slider + let volume_slider = gtk4::Scale::builder() + .orientation(gtk4::Orientation::Horizontal) + .width_request(100) + .draw_value(false) + .build(); + volume_slider.set_range(0.0, 100.0); + volume_slider.set_increments(5.0, 10.0); + volume_slider.add_css_class("volume-slider"); + + // Set initial value from daemon + if let Some(info) = crate::volume::get_volume_status() { + volume_slider.set_value(info.percent as f64); + } + + // Handle slider value changes + let slider_changing = std::rc::Rc::new(std::cell::Cell::new(false)); + let slider_changing_clone = slider_changing.clone(); + + volume_slider.connect_change_value(move |slider, _, value| { + slider_changing_clone.set(true); + let percent = value.clamp(0.0, 100.0) as u8; + + // Update in background thread to avoid blocking UI + std::thread::spawn(move || { + if let Err(e) = crate::volume::set_volume(percent) { + tracing::error!("Failed to set volume: {}", e); + } + }); + + // Allow the slider to update + slider.set_value(value); + glib::Propagation::Stop + }); + + volume_box.append(&volume_slider); + + // Volume percentage label + let volume_label = gtk4::Label::new(Some("--%")); + volume_label.add_css_class("volume-label"); + volume_label.set_width_chars(4); + volume_box.append(&volume_label); + + right_box.append(&volume_box); // Battery indicator let battery_box = gtk4::Box::builder() @@ -258,6 +310,9 @@ fn build_hud_content(state: SharedState) -> gtk4::Box { let battery_icon_clone = battery_icon.clone(); let battery_label_clone = battery_label.clone(); let volume_button_clone = volume_button.clone(); + let volume_slider_clone = volume_slider.clone(); + let volume_label_clone = volume_label.clone(); + let slider_changing_for_update = slider_changing.clone(); glib::timeout_add_local(Duration::from_millis(500), move || { // Update session state @@ -316,9 +371,31 @@ fn build_hud_content(state: SharedState) -> gtk4::Box { battery_label_clone.set_text("--%"); } - // Update volume - let volume = VolumeStatus::read(); - volume_button_clone.set_icon_name(volume.icon_name()); + // Update volume from daemon + if let Some(volume) = crate::volume::get_volume_status() { + volume_button_clone.set_icon_name(volume.icon_name()); + volume_label_clone.set_text(&format!("{}%", volume.percent)); + + // Only update slider if user is not actively dragging it + if !slider_changing_for_update.get() { + volume_slider_clone.set_value(volume.percent as f64); + } + // Reset the changing flag after a short delay + slider_changing_for_update.set(false); + + // Disable slider when muted or when restrictions don't allow changes + let slider_enabled = !volume.muted && volume.restrictions.allow_change; + volume_slider_clone.set_sensitive(slider_enabled); + volume_button_clone.set_sensitive(volume.restrictions.allow_mute); + + // Update slider range based on restrictions + let min = volume.restrictions.min_volume.unwrap_or(0) as f64; + let max = volume.restrictions.max_volume.unwrap_or(100) as f64; + volume_slider_clone.set_range(min, max); + } else { + volume_label_clone.set_text("--%"); + volume_slider_clone.set_sensitive(false); + } glib::ControlFlow::Continue }); @@ -416,6 +493,48 @@ fn load_css() { font-size: 12px; color: var(--text-primary); } + + .volume-control { + padding: 0 4px; + } + + .volume-slider { + min-width: 80px; + } + + .volume-slider trough { + min-height: 4px; + border-radius: 2px; + background-color: rgba(255, 255, 255, 0.2); + } + + .volume-slider highlight { + min-height: 4px; + border-radius: 2px; + background-color: var(--color-info); + } + + .volume-slider slider { + min-width: 12px; + min-height: 12px; + border-radius: 50%; + background-color: var(--text-primary); + } + + .volume-slider:disabled trough { + background-color: rgba(255, 255, 255, 0.1); + } + + .volume-slider:disabled highlight { + background-color: rgba(136, 192, 208, 0.5); + } + + .volume-label { + font-size: 12px; + color: var(--text-secondary); + min-width: 3em; + text-align: right; + } "#; let provider = gtk4::CssProvider::new(); diff --git a/crates/shepherd-hud/src/volume.rs b/crates/shepherd-hud/src/volume.rs index b6cff3a..dbd6c06 100644 --- a/crates/shepherd-hud/src/volume.rs +++ b/crates/shepherd-hud/src/volume.rs @@ -1,131 +1,181 @@ //! Volume monitoring and control module //! -//! Monitors and controls system volume via PulseAudio/PipeWire. -//! Uses the `pactl` command-line tool for simplicity. +//! Provides volume status and control via the shepherdd daemon. +//! The daemon handles actual volume control and enforces restrictions. -use std::process::Command; +use shepherd_api::{Command, ResponsePayload, VolumeInfo}; +use shepherd_ipc::IpcClient; +use std::path::PathBuf; +use tokio::runtime::Runtime; -/// Volume status -#[derive(Debug, Clone, Default)] -pub struct VolumeStatus { - /// Volume percentage (0-100+) - pub percent: u8, - /// Whether audio is muted - pub muted: bool, +/// Get the default socket path from environment or fallback +fn get_socket_path() -> PathBuf { + std::env::var("SHEPHERD_SOCKET") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("./dev-runtime/shepherd.sock")) } -impl VolumeStatus { - /// Read volume status using pactl - pub fn read() -> Self { - let mut status = VolumeStatus::default(); +/// Get current volume status from the daemon +pub fn get_volume_status() -> Option { + let socket_path = get_socket_path(); - // Get default sink info - if let Ok(output) = Command::new("pactl") - .args(["get-sink-volume", "@DEFAULT_SINK@"]) - .output() - { - if let Ok(stdout) = String::from_utf8(output.stdout) { - // Output looks like: "Volume: front-left: 65536 / 100% / -0.00 dB, front-right: ..." - if let Some(percent_str) = stdout.split('/').nth(1) { - if let Ok(percent) = percent_str.trim().trim_end_matches('%').parse::() { - status.percent = percent; + let rt = match Runtime::new() { + Ok(rt) => rt, + Err(e) => { + tracing::error!("Failed to create runtime: {}", e); + return None; + } + }; + + rt.block_on(async { + match IpcClient::connect(&socket_path).await { + Ok(mut client) => match client.send(Command::GetVolume).await { + Ok(response) => { + if let shepherd_api::ResponseResult::Ok(ResponsePayload::Volume(info)) = + response.result + { + Some(info) + } else { + None } } + Err(e) => { + tracing::error!("Failed to get volume: {}", e); + None + } + }, + Err(e) => { + tracing::debug!("Failed to connect to daemon for volume: {}", e); + None } } + }) +} - // Check mute status - if let Ok(output) = Command::new("pactl") - .args(["get-sink-mute", "@DEFAULT_SINK@"]) - .output() - { - if let Ok(stdout) = String::from_utf8(output.stdout) { - // Output looks like: "Mute: yes" or "Mute: no" - status.muted = stdout.contains("yes"); +/// Toggle mute state via the daemon +pub fn toggle_mute() -> anyhow::Result<()> { + let socket_path = get_socket_path(); + + let rt = Runtime::new()?; + + rt.block_on(async { + let mut client = IpcClient::connect(&socket_path).await?; + let response = client.send(Command::ToggleMute).await?; + + match response.result { + shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeSet) => Ok(()), + shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeDenied { reason }) => { + Err(anyhow::anyhow!("Volume denied: {}", reason)) } + shepherd_api::ResponseResult::Err(e) => { + Err(anyhow::anyhow!("Error: {}", e.message)) + } + _ => Err(anyhow::anyhow!("Unexpected response")), } + }) +} - status - } +/// Increase volume by a step via the daemon +pub fn volume_up(step: u8) -> anyhow::Result<()> { + let socket_path = get_socket_path(); - /// Toggle mute state - pub fn toggle_mute() -> anyhow::Result<()> { - Command::new("pactl") - .args(["set-sink-mute", "@DEFAULT_SINK@", "toggle"]) - .status()?; - Ok(()) - } + let rt = Runtime::new()?; - /// Increase volume by a step - pub fn volume_up(step: u8) -> anyhow::Result<()> { - Command::new("pactl") - .args([ - "set-sink-volume", - "@DEFAULT_SINK@", - &format!("+{}%", step), - ]) - .status()?; - Ok(()) - } + rt.block_on(async { + let mut client = IpcClient::connect(&socket_path).await?; + let response = client.send(Command::VolumeUp { step }).await?; - /// Decrease volume by a step - pub fn volume_down(step: u8) -> anyhow::Result<()> { - Command::new("pactl") - .args([ - "set-sink-volume", - "@DEFAULT_SINK@", - &format!("-{}%", step), - ]) - .status()?; - Ok(()) - } - - /// Set volume to a specific percentage - pub fn set_volume(percent: u8) -> anyhow::Result<()> { - Command::new("pactl") - .args(["set-sink-volume", "@DEFAULT_SINK@", &format!("{}%", percent)]) - .status()?; - Ok(()) - } - - /// Get an icon name for the current volume status - pub fn icon_name(&self) -> &'static str { - if self.muted { - "audio-volume-muted-symbolic" - } else if self.percent == 0 { - "audio-volume-muted-symbolic" - } else if self.percent < 33 { - "audio-volume-low-symbolic" - } else if self.percent < 66 { - "audio-volume-medium-symbolic" - } else { - "audio-volume-high-symbolic" + match response.result { + shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeSet) => Ok(()), + shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeDenied { reason }) => { + Err(anyhow::anyhow!("Volume denied: {}", reason)) + } + shepherd_api::ResponseResult::Err(e) => { + Err(anyhow::anyhow!("Error: {}", e.message)) + } + _ => Err(anyhow::anyhow!("Unexpected response")), } - } + }) +} + +/// Decrease volume by a step via the daemon +pub fn volume_down(step: u8) -> anyhow::Result<()> { + let socket_path = get_socket_path(); + + let rt = Runtime::new()?; + + rt.block_on(async { + let mut client = IpcClient::connect(&socket_path).await?; + let response = client.send(Command::VolumeDown { step }).await?; + + match response.result { + shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeSet) => Ok(()), + shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeDenied { reason }) => { + Err(anyhow::anyhow!("Volume denied: {}", reason)) + } + shepherd_api::ResponseResult::Err(e) => { + Err(anyhow::anyhow!("Error: {}", e.message)) + } + _ => Err(anyhow::anyhow!("Unexpected response")), + } + }) +} + +/// Set volume to a specific percentage via the daemon +pub fn set_volume(percent: u8) -> anyhow::Result<()> { + let socket_path = get_socket_path(); + + let rt = Runtime::new()?; + + rt.block_on(async { + let mut client = IpcClient::connect(&socket_path).await?; + let response = client.send(Command::SetVolume { percent }).await?; + + match response.result { + shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeSet) => Ok(()), + shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeDenied { reason }) => { + Err(anyhow::anyhow!("Volume denied: {}", reason)) + } + shepherd_api::ResponseResult::Err(e) => { + Err(anyhow::anyhow!("Error: {}", e.message)) + } + _ => Err(anyhow::anyhow!("Unexpected response")), + } + }) } #[cfg(test)] mod tests { - use super::*; + use shepherd_api::VolumeRestrictions; #[test] fn test_volume_icon_names() { - let status = VolumeStatus { + // Test that VolumeInfo::icon_name works correctly + let info = shepherd_api::VolumeInfo { percent: 0, muted: false, + available: true, + backend: Some("test".into()), + restrictions: VolumeRestrictions::unrestricted(), }; - assert_eq!(status.icon_name(), "audio-volume-muted-symbolic"); + assert_eq!(info.icon_name(), "audio-volume-muted-symbolic"); - let status = VolumeStatus { + let info = shepherd_api::VolumeInfo { percent: 50, muted: false, + available: true, + backend: Some("test".into()), + restrictions: VolumeRestrictions::unrestricted(), }; - assert_eq!(status.icon_name(), "audio-volume-medium-symbolic"); + assert_eq!(info.icon_name(), "audio-volume-medium-symbolic"); - let status = VolumeStatus { + let info = shepherd_api::VolumeInfo { percent: 100, muted: true, + available: true, + backend: Some("test".into()), + restrictions: VolumeRestrictions::unrestricted(), }; - assert_eq!(status.icon_name(), "audio-volume-muted-symbolic"); + assert_eq!(info.icon_name(), "audio-volume-muted-symbolic"); } } diff --git a/crates/shepherd-launcher-ui/src/state.rs b/crates/shepherd-launcher-ui/src/state.rs index e267b74..5b97745 100644 --- a/crates/shepherd-launcher-ui/src/state.rs +++ b/crates/shepherd-launcher-ui/src/state.rs @@ -115,6 +115,9 @@ impl SharedState { EventPayload::AuditEntry { .. } => { // Audit events are for admin clients, ignore } + EventPayload::VolumeChanged { .. } => { + // Volume events are handled by HUD + } } } diff --git a/crates/shepherdd/src/main.rs b/crates/shepherdd/src/main.rs index 639af88..5ce9a6c 100644 --- a/crates/shepherdd/src/main.rs +++ b/crates/shepherdd/src/main.rs @@ -7,18 +7,20 @@ //! - Core engine //! - Host adapter (Linux) //! - IPC server +//! - Volume control use anyhow::{Context, Result}; use chrono::Local; use clap::Parser; use shepherd_api::{ Command, DaemonStateSnapshot, ErrorCode, ErrorInfo, Event, EventPayload, HealthStatus, - Response, ResponsePayload, SessionEndReason, StopMode, API_VERSION, + Response, ResponsePayload, SessionEndReason, StopMode, VolumeInfo, VolumeRestrictions, + API_VERSION, }; -use shepherd_config::{load_config, Policy}; +use shepherd_config::{load_config, Policy, VolumePolicy}; use shepherd_core::{CoreEngine, CoreEvent, LaunchDecision, StopDecision}; -use shepherd_host_api::{HostAdapter, HostEvent, StopMode as HostStopMode}; -use shepherd_host_linux::LinuxHost; +use shepherd_host_api::{HostAdapter, HostEvent, StopMode as HostStopMode, VolumeController}; +use shepherd_host_linux::{LinuxHost, LinuxVolumeController}; use shepherd_ipc::{IpcServer, ServerMessage}; use shepherd_store::{AuditEvent, AuditEventType, SqliteStore, Store}; use shepherd_util::{ClientId, EntryId, MonotonicInstant, RateLimiter}; @@ -55,6 +57,7 @@ struct Args { struct Daemon { engine: CoreEngine, host: Arc, + volume: Arc, ipc: Arc, store: Arc, rate_limiter: RateLimiter, @@ -102,6 +105,17 @@ impl Daemon { // Initialize host adapter let host = Arc::new(LinuxHost::new()); + // Initialize volume controller + let volume = Arc::new(LinuxVolumeController::new()); + if volume.capabilities().available { + info!( + backend = ?volume.capabilities().backend, + "Volume controller initialized" + ); + } else { + warn!("No sound backend detected, volume control unavailable"); + } + // Initialize core engine let engine = CoreEngine::new(policy, store.clone(), host.capabilities().clone()); @@ -117,6 +131,7 @@ impl Daemon { Ok(Self { engine, host, + volume, ipc: Arc::new(ipc), store, rate_limiter, @@ -139,6 +154,7 @@ impl Daemon { let engine = Arc::new(Mutex::new(self.engine)); let rate_limiter = Arc::new(Mutex::new(self.rate_limiter)); let host = self.host.clone(); + let volume = self.volume.clone(); let store = self.store.clone(); // Spawn IPC accept task @@ -179,7 +195,7 @@ impl Daemon { // IPC messages Some(msg) = ipc_messages.recv() => { - Self::handle_ipc_message(&engine, &host, &ipc_ref, &store, &rate_limiter, msg).await; + Self::handle_ipc_message(&engine, &host, &volume, &ipc_ref, &store, &rate_limiter, msg).await; } } } @@ -367,6 +383,7 @@ impl Daemon { async fn handle_ipc_message( engine: &Arc>, host: &Arc, + volume: &Arc, ipc: &Arc, store: &Arc, rate_limiter: &Arc>, @@ -388,7 +405,7 @@ impl Daemon { } let response = - Self::handle_command(engine, host, ipc, store, &client_id, request.request_id, request.command) + Self::handle_command(engine, host, volume, ipc, store, &client_id, request.request_id, request.command) .await; let _ = ipc.send_response(&client_id, response).await; @@ -430,6 +447,7 @@ impl Daemon { async fn handle_command( engine: &Arc>, host: &Arc, + volume: &Arc, ipc: &Arc, store: &Arc, client_id: &ClientId, @@ -676,9 +694,233 @@ impl Daemon { } } + Command::GetVolume => { + let restrictions = Self::get_current_volume_restrictions(&engine).await; + + match volume.get_status().await { + Ok(status) => { + let info = VolumeInfo { + percent: status.percent, + muted: status.muted, + available: volume.capabilities().available, + backend: volume.capabilities().backend.clone(), + restrictions, + }; + Response::success(request_id, ResponsePayload::Volume(info)) + } + Err(e) => { + let info = VolumeInfo { + percent: 0, + muted: false, + available: false, + backend: None, + restrictions, + }; + warn!(error = %e, "Failed to get volume status"); + Response::success(request_id, ResponsePayload::Volume(info)) + } + } + } + + Command::SetVolume { percent } => { + let restrictions = Self::get_current_volume_restrictions(&engine).await; + + if !restrictions.allow_change { + return Response::success( + request_id, + ResponsePayload::VolumeDenied { + reason: "Volume changes are not allowed".into(), + }, + ); + } + + let clamped = restrictions.clamp_volume(percent); + + match volume.set_volume(clamped).await { + Ok(()) => { + // Broadcast volume change + if let Ok(status) = volume.get_status().await { + ipc.broadcast_event(Event::new(EventPayload::VolumeChanged { + percent: status.percent, + muted: status.muted, + })); + } + Response::success(request_id, ResponsePayload::VolumeSet) + } + Err(e) => Response::success( + request_id, + ResponsePayload::VolumeDenied { + reason: e.to_string(), + }, + ), + } + } + + Command::VolumeUp { step } => { + let restrictions = Self::get_current_volume_restrictions(&engine).await; + + if !restrictions.allow_change { + return Response::success( + request_id, + ResponsePayload::VolumeDenied { + reason: "Volume changes are not allowed".into(), + }, + ); + } + + // Get current volume and check if we'd exceed max + let current = volume.get_status().await.map(|s| s.percent).unwrap_or(0); + let target = current.saturating_add(step); + let clamped = restrictions.clamp_volume(target); + + match volume.set_volume(clamped).await { + Ok(()) => { + if let Ok(status) = volume.get_status().await { + ipc.broadcast_event(Event::new(EventPayload::VolumeChanged { + percent: status.percent, + muted: status.muted, + })); + } + Response::success(request_id, ResponsePayload::VolumeSet) + } + Err(e) => Response::success( + request_id, + ResponsePayload::VolumeDenied { + reason: e.to_string(), + }, + ), + } + } + + Command::VolumeDown { step } => { + let restrictions = Self::get_current_volume_restrictions(&engine).await; + + if !restrictions.allow_change { + return Response::success( + request_id, + ResponsePayload::VolumeDenied { + reason: "Volume changes are not allowed".into(), + }, + ); + } + + // Get current volume and check if we'd go below min + let current = volume.get_status().await.map(|s| s.percent).unwrap_or(0); + let target = current.saturating_sub(step); + let clamped = restrictions.clamp_volume(target); + + match volume.set_volume(clamped).await { + Ok(()) => { + if let Ok(status) = volume.get_status().await { + ipc.broadcast_event(Event::new(EventPayload::VolumeChanged { + percent: status.percent, + muted: status.muted, + })); + } + Response::success(request_id, ResponsePayload::VolumeSet) + } + Err(e) => Response::success( + request_id, + ResponsePayload::VolumeDenied { + reason: e.to_string(), + }, + ), + } + } + + Command::ToggleMute => { + let restrictions = Self::get_current_volume_restrictions(&engine).await; + + if !restrictions.allow_mute { + return Response::success( + request_id, + ResponsePayload::VolumeDenied { + reason: "Mute toggle is not allowed".into(), + }, + ); + } + + match volume.toggle_mute().await { + Ok(()) => { + if let Ok(status) = volume.get_status().await { + ipc.broadcast_event(Event::new(EventPayload::VolumeChanged { + percent: status.percent, + muted: status.muted, + })); + } + Response::success(request_id, ResponsePayload::VolumeSet) + } + Err(e) => Response::success( + request_id, + ResponsePayload::VolumeDenied { + reason: e.to_string(), + }, + ), + } + } + + Command::SetMute { muted } => { + let restrictions = Self::get_current_volume_restrictions(&engine).await; + + if !restrictions.allow_mute { + return Response::success( + request_id, + ResponsePayload::VolumeDenied { + reason: "Mute toggle is not allowed".into(), + }, + ); + } + + match volume.set_mute(muted).await { + Ok(()) => { + if let Ok(status) = volume.get_status().await { + ipc.broadcast_event(Event::new(EventPayload::VolumeChanged { + percent: status.percent, + muted: status.muted, + })); + } + Response::success(request_id, ResponsePayload::VolumeSet) + } + Err(e) => Response::success( + request_id, + ResponsePayload::VolumeDenied { + reason: e.to_string(), + }, + ), + } + } + Command::Ping => Response::success(request_id, ResponsePayload::Pong), } } + + /// Get the current volume restrictions based on policy and active session + async fn get_current_volume_restrictions( + engine: &Arc>, + ) -> VolumeRestrictions { + let eng = engine.lock().await; + + // Check if there's an active session with volume restrictions + if let Some(session) = eng.current_session() { + if let Some(entry) = eng.policy().get_entry(&session.plan.entry_id) { + if let Some(ref vol_policy) = entry.volume { + return Self::convert_volume_policy(vol_policy); + } + } + } + + // Fall back to global policy + Self::convert_volume_policy(&eng.policy().volume) + } + + fn convert_volume_policy(policy: &VolumePolicy) -> VolumeRestrictions { + VolumeRestrictions { + max_volume: policy.max_volume, + min_volume: policy.min_volume, + allow_mute: policy.allow_mute, + allow_change: policy.allow_change, + } + } } #[tokio::main] diff --git a/crates/shepherdd/tests/integration.rs b/crates/shepherdd/tests/integration.rs index e3b9076..b325141 100644 --- a/crates/shepherdd/tests/integration.rs +++ b/crates/shepherdd/tests/integration.rs @@ -48,12 +48,14 @@ fn make_test_policy() -> Policy { message_template: Some("2 seconds left!".into()), }, ], + volume: None, disabled: false, disabled_reason: None, }, ], default_warnings: vec![], default_max_run: Some(Duration::from_secs(3600)), + volume: Default::default(), } }