Add volume policy

This commit is contained in:
Albert Armea 2025-12-28 09:30:54 -05:00
parent 234a64de3d
commit fb7503eeb4
17 changed files with 1272 additions and 107 deletions

View file

@ -13,6 +13,14 @@ config_version = 1
# Set to 0 for unlimited (no time limit) # Set to 0 for unlimited (no time limit)
default_max_run_seconds = 3600 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 # Default warning thresholds
[[daemon.default_warnings]] [[daemon.default_warnings]]
seconds_before = 300 seconds_before = 300
@ -96,6 +104,10 @@ seconds_before = 30
severity = "critical" severity = "critical"
message = "30 seconds! Save NOW!" message = "30 seconds! Save NOW!"
# Entry-specific volume restrictions (overrides global)
[entries.volume]
max_volume = 60 # Limit volume during gaming sessions
# Example: Educational game # Example: Educational game
[[entries]] [[entries]]
id = "tuxmath" id = "tuxmath"

View file

@ -127,6 +127,26 @@ pub enum Command {
/// Get health status /// Get health status
GetHealth, 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 // Admin commands
/// Extend the current session (admin only) /// 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. None if session is unlimited (can't be extended).
new_deadline: Option<DateTime<Local>>, new_deadline: Option<DateTime<Local>>,
}, },
Volume(crate::VolumeInfo),
VolumeSet,
VolumeDenied {
reason: String,
},
Pong, Pong,
} }

View file

@ -74,6 +74,12 @@ pub enum EventPayload {
enabled: bool, enabled: bool,
}, },
/// Volume status changed
VolumeChanged {
percent: u8,
muted: bool,
},
/// Daemon is shutting down /// Daemon is shutting down
Shutdown, Shutdown,

View file

@ -249,6 +249,70 @@ pub struct HealthStatus {
pub store_ok: bool, 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<String>,
/// 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<u8>,
/// Minimum volume percentage allowed
pub min_volume: Option<u8>,
/// 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -1,6 +1,6 @@
//! Validated policy structures //! 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 crate::validation::{parse_days, parse_time};
use shepherd_api::{EntryKind, WarningSeverity, WarningThreshold}; use shepherd_api::{EntryKind, WarningSeverity, WarningThreshold};
use shepherd_util::{DaysOfWeek, EntryId, TimeWindow, WallClock}; use shepherd_util::{DaysOfWeek, EntryId, TimeWindow, WallClock};
@ -22,6 +22,9 @@ pub struct Policy {
/// Default max run duration. None means unlimited. /// Default max run duration. None means unlimited.
pub default_max_run: Option<Duration>, pub default_max_run: Option<Duration>,
/// Global volume restrictions
pub volume: VolumePolicy,
} }
impl Policy { impl Policy {
@ -41,10 +44,17 @@ impl Policy {
.map(seconds_to_duration_or_unlimited) .map(seconds_to_duration_or_unlimited)
.unwrap_or(Some(Duration::from_secs(3600))); // 1 hour default .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 let entries = raw
.entries .entries
.into_iter() .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(); .collect();
Self { Self {
@ -52,6 +62,7 @@ impl Policy {
entries, entries,
default_warnings, default_warnings,
default_max_run, default_max_run,
volume: global_volume,
} }
} }
@ -105,6 +116,7 @@ pub struct Entry {
pub availability: AvailabilityPolicy, pub availability: AvailabilityPolicy,
pub limits: LimitsPolicy, pub limits: LimitsPolicy,
pub warnings: Vec<WarningThreshold>, pub warnings: Vec<WarningThreshold>,
pub volume: Option<VolumePolicy>,
pub disabled: bool, pub disabled: bool,
pub disabled_reason: Option<String>, pub disabled_reason: Option<String>,
} }
@ -114,6 +126,7 @@ impl Entry {
raw: RawEntry, raw: RawEntry,
default_warnings: &[WarningThreshold], default_warnings: &[WarningThreshold],
default_max_run: Option<Duration>, default_max_run: Option<Duration>,
_global_volume: &VolumePolicy,
) -> Self { ) -> Self {
let kind = convert_entry_kind(raw.kind); let kind = convert_entry_kind(raw.kind);
let availability = raw let availability = raw
@ -132,6 +145,7 @@ impl Entry {
.warnings .warnings
.map(|w| w.into_iter().map(convert_warning).collect()) .map(|w| w.into_iter().map(convert_warning).collect())
.unwrap_or_else(|| default_warnings.to_vec()); .unwrap_or_else(|| default_warnings.to_vec());
let volume = raw.volume.as_ref().map(convert_volume_config);
Self { Self {
id: EntryId::new(raw.id), id: EntryId::new(raw.id),
@ -141,6 +155,7 @@ impl Entry {
availability, availability,
limits, limits,
warnings, warnings,
volume,
disabled: raw.disabled, disabled: raw.disabled,
disabled_reason: raw.disabled_reason, disabled_reason: raw.disabled_reason,
} }
@ -190,6 +205,38 @@ pub struct LimitsPolicy {
pub cooldown: Option<Duration>, pub cooldown: Option<Duration>,
} }
/// Volume control policy
#[derive(Debug, Clone, Default)]
pub struct VolumePolicy {
/// Maximum volume percentage allowed (enforced by daemon)
pub max_volume: Option<u8>,
/// Minimum volume percentage allowed (enforced by daemon)
pub min_volume: Option<u8>,
/// 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 // Conversion helpers
fn convert_entry_kind(raw: RawEntryKind) -> EntryKind { 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 { fn convert_time_window(raw: crate::schema::RawTimeWindow) -> TimeWindow {
let days_mask = parse_days(&raw.days).unwrap_or(0x7F); let days_mask = parse_days(&raw.days).unwrap_or(0x7F);
let (start_h, start_m) = parse_time(&raw.start).unwrap_or((0, 0)); let (start_h, start_m) = parse_time(&raw.start).unwrap_or((0, 0));

View file

@ -36,6 +36,10 @@ pub struct RawDaemonConfig {
/// Default max run duration /// Default max run duration
pub default_max_run_seconds: Option<u64>, pub default_max_run_seconds: Option<u64>,
/// Global volume restrictions
#[serde(default)]
pub volume: Option<RawVolumeConfig>,
} }
/// Raw entry definition /// Raw entry definition
@ -65,6 +69,10 @@ pub struct RawEntry {
#[serde(default)] #[serde(default)]
pub warnings: Option<Vec<RawWarningThreshold>>, pub warnings: Option<Vec<RawWarningThreshold>>,
/// Volume restrictions for this entry (overrides global)
#[serde(default)]
pub volume: Option<RawVolumeConfig>,
/// Explicitly disabled /// Explicitly disabled
#[serde(default)] #[serde(default)]
pub disabled: bool, pub disabled: bool,
@ -181,6 +189,28 @@ fn default_severity() -> String {
"warn".to_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<u8>,
/// Minimum volume percentage allowed (0-100)
pub min_volume: Option<u8>,
/// 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -260,6 +260,7 @@ mod tests {
availability: None, availability: None,
limits: None, limits: None,
warnings: None, warnings: None,
volume: None,
disabled: false, disabled: false,
disabled_reason: None, disabled_reason: None,
}, },
@ -276,6 +277,7 @@ mod tests {
availability: None, availability: None,
limits: None, limits: None,
warnings: None, warnings: None,
volume: None,
disabled: false, disabled: false,
disabled_reason: None, disabled_reason: None,
}, },

View file

@ -574,11 +574,13 @@ mod tests {
cooldown: None, cooldown: None,
}, },
warnings: vec![], warnings: vec![],
volume: None,
disabled: false, disabled: false,
disabled_reason: None, disabled_reason: None,
}], }],
default_warnings: vec![], default_warnings: vec![],
default_max_run: Some(Duration::from_secs(3600)), default_max_run: Some(Duration::from_secs(3600)),
volume: Default::default(),
} }
} }
@ -655,12 +657,14 @@ mod tests {
severity: WarningSeverity::Warn, severity: WarningSeverity::Warn,
message_template: Some("1 minute left".into()), message_template: Some("1 minute left".into()),
}], }],
volume: None,
disabled: false, disabled: false,
disabled_reason: None, disabled_reason: None,
}], }],
daemon: Default::default(), daemon: Default::default(),
default_warnings: vec![], default_warnings: vec![],
default_max_run: Some(Duration::from_secs(3600)), default_max_run: Some(Duration::from_secs(3600)),
volume: Default::default(),
}; };
let store = Arc::new(SqliteStore::in_memory().unwrap()); let store = Arc::new(SqliteStore::in_memory().unwrap());
@ -714,12 +718,14 @@ mod tests {
cooldown: None, cooldown: None,
}, },
warnings: vec![], warnings: vec![],
volume: None,
disabled: false, disabled: false,
disabled_reason: None, disabled_reason: None,
}], }],
daemon: Default::default(), daemon: Default::default(),
default_warnings: vec![], default_warnings: vec![],
default_max_run: Some(Duration::from_secs(3600)), default_max_run: Some(Duration::from_secs(3600)),
volume: Default::default(),
}; };
let store = Arc::new(SqliteStore::in_memory().unwrap()); let store = Arc::new(SqliteStore::in_memory().unwrap());

View file

@ -7,8 +7,10 @@ mod capabilities;
mod handle; mod handle;
mod mock; mod mock;
mod traits; mod traits;
mod volume;
pub use capabilities::*; pub use capabilities::*;
pub use handle::*; pub use handle::*;
pub use mock::*; pub use mock::*;
pub use traits::*; pub use traits::*;
pub use volume::*;

View file

@ -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<T> = Result<T, VolumeError>;
/// 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<String>,
/// 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<u8>,
/// Minimum volume percentage allowed (enforced by daemon)
pub min_volume: Option<u8>,
/// 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<VolumeStatus>;
/// 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);
}
}

View file

@ -5,9 +5,12 @@
//! - Graceful (SIGTERM) and forceful (SIGKILL) termination //! - Graceful (SIGTERM) and forceful (SIGKILL) termination
//! - Exit observation //! - Exit observation
//! - stdout/stderr capture //! - stdout/stderr capture
//! - Volume control with auto-detection of sound systems
mod adapter; mod adapter;
mod process; mod process;
mod volume;
pub use adapter::*; pub use adapter::*;
pub use process::*; pub use process::*;
pub use volume::*;

View file

@ -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<Self> {
// 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<SoundBackend>,
}
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<VolumeStatus> {
// 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::<f32>().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<VolumeStatus> {
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::<u8>() {
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<VolumeStatus> {
// 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::<u8>() {
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<VolumeStatus> {
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");
}
}

View file

@ -6,7 +6,6 @@
use crate::battery::BatteryStatus; use crate::battery::BatteryStatus;
use crate::state::{SessionState, SharedState}; use crate::state::{SessionState, SharedState};
use crate::time_display::TimeDisplay; use crate::time_display::TimeDisplay;
use crate::volume::VolumeStatus;
use gtk4::glib; use gtk4::glib;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, Layer, LayerShell}; use gtk4_layer_shell::{Edge, Layer, LayerShell};
@ -181,18 +180,71 @@ fn build_hud_content(state: SharedState) -> gtk4::Box {
.halign(gtk4::Align::End) .halign(gtk4::Align::End)
.build(); .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() let volume_button = gtk4::Button::builder()
.icon_name("audio-volume-medium-symbolic") .icon_name("audio-volume-medium-symbolic")
.has_frame(false) .has_frame(false)
.tooltip_text("Toggle mute")
.build(); .build();
volume_button.add_css_class("indicator-button"); volume_button.add_css_class("indicator-button");
volume_button.connect_clicked(|_| { 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); 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 // Battery indicator
let battery_box = gtk4::Box::builder() 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_icon_clone = battery_icon.clone();
let battery_label_clone = battery_label.clone(); let battery_label_clone = battery_label.clone();
let volume_button_clone = volume_button.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 || { glib::timeout_add_local(Duration::from_millis(500), move || {
// Update session state // Update session state
@ -316,9 +371,31 @@ fn build_hud_content(state: SharedState) -> gtk4::Box {
battery_label_clone.set_text("--%"); battery_label_clone.set_text("--%");
} }
// Update volume // Update volume from daemon
let volume = VolumeStatus::read(); if let Some(volume) = crate::volume::get_volume_status() {
volume_button_clone.set_icon_name(volume.icon_name()); 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 glib::ControlFlow::Continue
}); });
@ -416,6 +493,48 @@ fn load_css() {
font-size: 12px; font-size: 12px;
color: var(--text-primary); 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(); let provider = gtk4::CssProvider::new();

View file

@ -1,131 +1,181 @@
//! Volume monitoring and control module //! Volume monitoring and control module
//! //!
//! Monitors and controls system volume via PulseAudio/PipeWire. //! Provides volume status and control via the shepherdd daemon.
//! Uses the `pactl` command-line tool for simplicity. //! 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 /// Get the default socket path from environment or fallback
#[derive(Debug, Clone, Default)] fn get_socket_path() -> PathBuf {
pub struct VolumeStatus { std::env::var("SHEPHERD_SOCKET")
/// Volume percentage (0-100+) .map(PathBuf::from)
pub percent: u8, .unwrap_or_else(|_| PathBuf::from("./dev-runtime/shepherd.sock"))
/// Whether audio is muted
pub muted: bool,
} }
impl VolumeStatus { /// Get current volume status from the daemon
/// Read volume status using pactl pub fn get_volume_status() -> Option<VolumeInfo> {
pub fn read() -> Self { let socket_path = get_socket_path();
let mut status = VolumeStatus::default();
// Get default sink info let rt = match Runtime::new() {
if let Ok(output) = Command::new("pactl") Ok(rt) => rt,
.args(["get-sink-volume", "@DEFAULT_SINK@"]) Err(e) => {
.output() 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
{ {
if let Ok(stdout) = String::from_utf8(output.stdout) { Some(info)
// 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::<u8>() {
status.percent = percent;
}
}
}
}
// 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");
}
}
status
}
/// Toggle mute state
pub fn toggle_mute() -> anyhow::Result<()> {
Command::new("pactl")
.args(["set-sink-mute", "@DEFAULT_SINK@", "toggle"])
.status()?;
Ok(())
}
/// 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(())
}
/// 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 { } else {
"audio-volume-high-symbolic" None
} }
} }
Err(e) => {
tracing::error!("Failed to get volume: {}", e);
None
}
},
Err(e) => {
tracing::debug!("Failed to connect to daemon for volume: {}", e);
None
}
}
})
}
/// 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")),
}
})
}
/// Increase volume by a step via the daemon
pub fn volume_up(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::VolumeUp { 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")),
}
})
}
/// 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use shepherd_api::VolumeRestrictions;
#[test] #[test]
fn test_volume_icon_names() { fn test_volume_icon_names() {
let status = VolumeStatus { // Test that VolumeInfo::icon_name works correctly
let info = shepherd_api::VolumeInfo {
percent: 0, percent: 0,
muted: false, 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, percent: 50,
muted: false, 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, percent: 100,
muted: true, 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");
} }
} }

View file

@ -115,6 +115,9 @@ impl SharedState {
EventPayload::AuditEntry { .. } => { EventPayload::AuditEntry { .. } => {
// Audit events are for admin clients, ignore // Audit events are for admin clients, ignore
} }
EventPayload::VolumeChanged { .. } => {
// Volume events are handled by HUD
}
} }
} }

View file

@ -7,18 +7,20 @@
//! - Core engine //! - Core engine
//! - Host adapter (Linux) //! - Host adapter (Linux)
//! - IPC server //! - IPC server
//! - Volume control
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use chrono::Local; use chrono::Local;
use clap::Parser; use clap::Parser;
use shepherd_api::{ use shepherd_api::{
Command, DaemonStateSnapshot, ErrorCode, ErrorInfo, Event, EventPayload, HealthStatus, 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_core::{CoreEngine, CoreEvent, LaunchDecision, StopDecision};
use shepherd_host_api::{HostAdapter, HostEvent, StopMode as HostStopMode}; use shepherd_host_api::{HostAdapter, HostEvent, StopMode as HostStopMode, VolumeController};
use shepherd_host_linux::LinuxHost; use shepherd_host_linux::{LinuxHost, LinuxVolumeController};
use shepherd_ipc::{IpcServer, ServerMessage}; use shepherd_ipc::{IpcServer, ServerMessage};
use shepherd_store::{AuditEvent, AuditEventType, SqliteStore, Store}; use shepherd_store::{AuditEvent, AuditEventType, SqliteStore, Store};
use shepherd_util::{ClientId, EntryId, MonotonicInstant, RateLimiter}; use shepherd_util::{ClientId, EntryId, MonotonicInstant, RateLimiter};
@ -55,6 +57,7 @@ struct Args {
struct Daemon { struct Daemon {
engine: CoreEngine, engine: CoreEngine,
host: Arc<LinuxHost>, host: Arc<LinuxHost>,
volume: Arc<LinuxVolumeController>,
ipc: Arc<IpcServer>, ipc: Arc<IpcServer>,
store: Arc<dyn Store>, store: Arc<dyn Store>,
rate_limiter: RateLimiter, rate_limiter: RateLimiter,
@ -102,6 +105,17 @@ impl Daemon {
// Initialize host adapter // Initialize host adapter
let host = Arc::new(LinuxHost::new()); 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 // Initialize core engine
let engine = CoreEngine::new(policy, store.clone(), host.capabilities().clone()); let engine = CoreEngine::new(policy, store.clone(), host.capabilities().clone());
@ -117,6 +131,7 @@ impl Daemon {
Ok(Self { Ok(Self {
engine, engine,
host, host,
volume,
ipc: Arc::new(ipc), ipc: Arc::new(ipc),
store, store,
rate_limiter, rate_limiter,
@ -139,6 +154,7 @@ impl Daemon {
let engine = Arc::new(Mutex::new(self.engine)); let engine = Arc::new(Mutex::new(self.engine));
let rate_limiter = Arc::new(Mutex::new(self.rate_limiter)); let rate_limiter = Arc::new(Mutex::new(self.rate_limiter));
let host = self.host.clone(); let host = self.host.clone();
let volume = self.volume.clone();
let store = self.store.clone(); let store = self.store.clone();
// Spawn IPC accept task // Spawn IPC accept task
@ -179,7 +195,7 @@ impl Daemon {
// IPC messages // IPC messages
Some(msg) = ipc_messages.recv() => { 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( async fn handle_ipc_message(
engine: &Arc<Mutex<CoreEngine>>, engine: &Arc<Mutex<CoreEngine>>,
host: &Arc<LinuxHost>, host: &Arc<LinuxHost>,
volume: &Arc<LinuxVolumeController>,
ipc: &Arc<IpcServer>, ipc: &Arc<IpcServer>,
store: &Arc<dyn Store>, store: &Arc<dyn Store>,
rate_limiter: &Arc<Mutex<RateLimiter>>, rate_limiter: &Arc<Mutex<RateLimiter>>,
@ -388,7 +405,7 @@ impl Daemon {
} }
let response = 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; .await;
let _ = ipc.send_response(&client_id, response).await; let _ = ipc.send_response(&client_id, response).await;
@ -430,6 +447,7 @@ impl Daemon {
async fn handle_command( async fn handle_command(
engine: &Arc<Mutex<CoreEngine>>, engine: &Arc<Mutex<CoreEngine>>,
host: &Arc<LinuxHost>, host: &Arc<LinuxHost>,
volume: &Arc<LinuxVolumeController>,
ipc: &Arc<IpcServer>, ipc: &Arc<IpcServer>,
store: &Arc<dyn Store>, store: &Arc<dyn Store>,
client_id: &ClientId, 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), 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<Mutex<CoreEngine>>,
) -> 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] #[tokio::main]

View file

@ -48,12 +48,14 @@ fn make_test_policy() -> Policy {
message_template: Some("2 seconds left!".into()), message_template: Some("2 seconds left!".into()),
}, },
], ],
volume: None,
disabled: false, disabled: false,
disabled_reason: None, disabled_reason: None,
}, },
], ],
default_warnings: vec![], default_warnings: vec![],
default_max_run: Some(Duration::from_secs(3600)), default_max_run: Some(Duration::from_secs(3600)),
volume: Default::default(),
} }
} }