Lint: electric boogaloo

This commit is contained in:
Albert Armea 2025-12-29 17:51:55 -05:00
parent 3fd49b2efd
commit 5e5e6f6806
22 changed files with 106 additions and 291 deletions

View file

@ -52,12 +52,16 @@ Run the test suite:
```sh ```sh
cargo test cargo test
# as run in CI:
cargo test --all-targets
``` ```
Run lint checks: Run lint checks:
```sh ```sh
cargo clippy cargo clippy
# as run in CI:
cargo clippy --all-targets -- -D warnings
``` ```

View file

@ -135,12 +135,6 @@ pub enum Command {
/// Set volume to a specific percentage /// Set volume to a specific percentage
SetVolume { percent: u8 }, SetVolume { percent: u8 },
/// Increase volume by a step
VolumeUp { step: u8 },
/// Decrease volume by a step
VolumeDown { step: u8 },
/// Toggle mute state /// Toggle mute state
ToggleMute, ToggleMute,

View file

@ -1,10 +1,9 @@
//! Validated policy structures //! Validated policy structures
use crate::schema::{RawConfig, RawDays, RawEntry, RawEntryKind, RawVolumeConfig, RawServiceConfig, RawWarningThreshold}; use crate::schema::{RawConfig, RawEntry, RawEntryKind, RawVolumeConfig, RawServiceConfig, 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};
use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;

View file

@ -113,8 +113,8 @@ fn validate_entry(entry: &RawEntry, config: &RawConfig) -> Vec<ValidationError>
.or(config.service.default_max_run_seconds); .or(config.service.default_max_run_seconds);
// Only validate warnings if max_run is Some and not 0 (unlimited) // Only validate warnings if max_run is Some and not 0 (unlimited)
if let (Some(warnings), Some(max_run)) = (&entry.warnings, max_run) { if let (Some(warnings), Some(max_run)) = (&entry.warnings, max_run)
if max_run > 0 { && max_run > 0 {
for warning in warnings { for warning in warnings {
if warning.seconds_before >= max_run { if warning.seconds_before >= max_run {
errors.push(ValidationError::WarningExceedsMaxRun { errors.push(ValidationError::WarningExceedsMaxRun {
@ -124,7 +124,6 @@ fn validate_entry(entry: &RawEntry, config: &RawConfig) -> Vec<ValidationError>
}); });
} }
} }
}
// Note: warnings are ignored for unlimited entries (max_run = 0) // Note: warnings are ignored for unlimited entries (max_run = 0)
} }

View file

@ -2,7 +2,7 @@
use chrono::{DateTime, Local}; use chrono::{DateTime, Local};
use shepherd_api::{ use shepherd_api::{
ServiceStateSnapshot, EntryKindTag, EntryView, ReasonCode, SessionEndReason, ServiceStateSnapshot, EntryView, ReasonCode, SessionEndReason,
WarningSeverity, API_VERSION, WarningSeverity, API_VERSION,
}; };
use shepherd_config::{Entry, Policy}; use shepherd_config::{Entry, Policy};
@ -11,7 +11,7 @@ use shepherd_store::{AuditEvent, AuditEventType, Store};
use shepherd_util::{EntryId, MonotonicInstant, SessionId}; use shepherd_util::{EntryId, MonotonicInstant, SessionId};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tracing::{debug, info, warn}; use tracing::{debug, info};
use crate::{ActiveSession, CoreEvent, SessionPlan, StopResult}; use crate::{ActiveSession, CoreEvent, SessionPlan, StopResult};
@ -128,22 +128,20 @@ impl CoreEngine {
} }
// Check cooldown // Check cooldown
if let Ok(Some(until)) = self.store.get_cooldown_until(&entry.id) { if let Ok(Some(until)) = self.store.get_cooldown_until(&entry.id)
if until > now { && until > now {
enabled = false; enabled = false;
reasons.push(ReasonCode::CooldownActive { available_at: until }); reasons.push(ReasonCode::CooldownActive { available_at: until });
} }
}
// Check daily quota // Check daily quota
if let Some(quota) = entry.limits.daily_quota { if let Some(quota) = entry.limits.daily_quota {
let today = now.date_naive(); let today = now.date_naive();
if let Ok(used) = self.store.get_usage(&entry.id, today) { if let Ok(used) = self.store.get_usage(&entry.id, today)
if used >= quota { && used >= quota {
enabled = false; enabled = false;
reasons.push(ReasonCode::QuotaExhausted { used, quota }); reasons.push(ReasonCode::QuotaExhausted { used, quota });
} }
}
} }
// Calculate max run if enabled (None when disabled, Some(None) flattened for unlimited) // Calculate max run if enabled (None when disabled, Some(None) flattened for unlimited)
@ -393,12 +391,11 @@ impl CoreEngine {
let _ = self.store.add_usage(&session.plan.entry_id, today, duration); let _ = self.store.add_usage(&session.plan.entry_id, today, duration);
// Set cooldown if configured // Set cooldown if configured
if let Some(entry) = self.policy.get_entry(&session.plan.entry_id) { if let Some(entry) = self.policy.get_entry(&session.plan.entry_id)
if let Some(cooldown) = entry.limits.cooldown { && let Some(cooldown) = entry.limits.cooldown {
let until = now + chrono::Duration::from_std(cooldown).unwrap(); let until = now + chrono::Duration::from_std(cooldown).unwrap();
let _ = self.store.set_cooldown_until(&session.plan.entry_id, until); let _ = self.store.set_cooldown_until(&session.plan.entry_id, until);
} }
}
// Log to audit // Log to audit
let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionEnded { let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionEnded {
@ -443,12 +440,11 @@ impl CoreEngine {
let _ = self.store.add_usage(&session.plan.entry_id, today, duration); let _ = self.store.add_usage(&session.plan.entry_id, today, duration);
// Set cooldown if configured // Set cooldown if configured
if let Some(entry) = self.policy.get_entry(&session.plan.entry_id) { if let Some(entry) = self.policy.get_entry(&session.plan.entry_id)
if let Some(cooldown) = entry.limits.cooldown { && let Some(cooldown) = entry.limits.cooldown {
let until = now + chrono::Duration::from_std(cooldown).unwrap(); let until = now + chrono::Duration::from_std(cooldown).unwrap();
let _ = self.store.set_cooldown_until(&session.plan.entry_id, until); let _ = self.store.set_cooldown_until(&session.plan.entry_id, until);
} }
}
// Log to audit // Log to audit
let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionEnded { let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionEnded {
@ -510,8 +506,8 @@ impl CoreEngine {
pub fn extend_current( pub fn extend_current(
&mut self, &mut self,
by: Duration, by: Duration,
now_mono: MonotonicInstant, _now_mono: MonotonicInstant,
now: DateTime<Local>, _now: DateTime<Local>,
) -> Option<DateTime<Local>> { ) -> Option<DateTime<Local>> {
let session = self.current_session.as_mut()?; let session = self.current_session.as_mut()?;

View file

@ -37,9 +37,7 @@ pub struct VolumeStatus {
impl VolumeStatus { impl VolumeStatus {
/// Get an icon name for the current volume status /// Get an icon name for the current volume status
pub fn icon_name(&self) -> &'static str { pub fn icon_name(&self) -> &'static str {
if self.muted { if self.muted || self.percent == 0 {
"audio-volume-muted-symbolic"
} else if self.percent == 0 {
"audio-volume-muted-symbolic" "audio-volume-muted-symbolic"
} else if self.percent < 33 { } else if self.percent < 33 {
"audio-volume-low-symbolic" "audio-volume-low-symbolic"

View file

@ -126,11 +126,10 @@ impl HostAdapter for LinuxHost {
// followed by any additional args. // followed by any additional args.
let mut argv = vec!["snap".to_string(), "run".to_string(), snap_name.clone()]; let mut argv = vec!["snap".to_string(), "run".to_string(), snap_name.clone()];
// If a custom command is specified (different from snap_name), add it // If a custom command is specified (different from snap_name), add it
if let Some(cmd) = command { if let Some(cmd) = command
if cmd != snap_name { && cmd != snap_name {
argv.push(cmd.clone()); argv.push(cmd.clone());
} }
}
argv.extend(args.clone()); argv.extend(args.clone());
(argv, env.clone(), None, Some(snap_name.clone())) (argv, env.clone(), None, Some(snap_name.clone()))
} }
@ -336,8 +335,8 @@ mod tests {
// Process should have exited // Process should have exited
match handle.payload() { match handle.payload() {
HostHandlePayload::Linux { pid, .. } => { HostHandlePayload::Linux { pid: _, .. } => {
let procs = host.processes.lock().unwrap(); let _procs = host.processes.lock().unwrap();
// Process may or may not still be tracked depending on monitor timing // Process may or may not still be tracked depending on monitor timing
} }
_ => panic!("Expected Linux handle"), _ => panic!("Expected Linux handle"),

View file

@ -264,7 +264,7 @@ impl ManagedProcess {
unsafe { unsafe {
cmd.pre_exec(|| { cmd.pre_exec(|| {
nix::unistd::setsid().map_err(|e| { nix::unistd::setsid().map_err(|e| {
std::io::Error::new(std::io::ErrorKind::Other, e.to_string()) std::io::Error::other(e.to_string())
})?; })?;
Ok(()) Ok(())
}); });
@ -304,14 +304,12 @@ impl ManagedProcess {
if let Some(paren_end) = stat.rfind(')') { if let Some(paren_end) = stat.rfind(')') {
let after_comm = &stat[paren_end + 2..]; let after_comm = &stat[paren_end + 2..];
let fields: Vec<&str> = after_comm.split_whitespace().collect(); let fields: Vec<&str> = after_comm.split_whitespace().collect();
if fields.len() >= 2 { if fields.len() >= 2
if let Ok(ppid) = fields[1].parse::<i32>() { && let Ok(ppid) = fields[1].parse::<i32>()
if ppid == parent_pid { && ppid == parent_pid {
descendants.push(pid); descendants.push(pid);
to_check.push(pid); to_check.push(pid);
}
} }
}
} }
} }
} }

View file

@ -147,11 +147,10 @@ impl LinuxVolumeController {
debug!("pactl get-sink-volume output: {}", stdout.trim()); debug!("pactl get-sink-volume output: {}", stdout.trim());
// Output: "Volume: front-left: 65536 / 100% / -0.00 dB, front-right: ..." // Output: "Volume: front-left: 65536 / 100% / -0.00 dB, front-right: ..."
if let Some(percent_str) = stdout.split('/').nth(1) { if let Some(percent_str) = stdout.split('/').nth(1)
if let Ok(percent) = percent_str.trim().trim_end_matches('%').parse::<u8>() { && let Ok(percent) = percent_str.trim().trim_end_matches('%').parse::<u8>() {
status.percent = percent; status.percent = percent;
} }
}
} }
// Check mute status // Check mute status
@ -184,13 +183,11 @@ impl LinuxVolumeController {
for line in stdout.lines() { for line in stdout.lines() {
if line.contains("Playback") && line.contains('%') { if line.contains("Playback") && line.contains('%') {
// Extract percentage: [100%] // Extract percentage: [100%]
if let Some(start) = line.find('[') { if let Some(start) = line.find('[')
if let Some(end) = line[start..].find('%') { && let Some(end) = line[start..].find('%')
if let Ok(percent) = line[start + 1..start + end].parse::<u8>() { && let Ok(percent) = line[start + 1..start + end].parse::<u8>() {
status.percent = percent; status.percent = percent;
}
} }
}
// Check mute status: [on] or [off] // Check mute status: [on] or [off]
status.muted = line.contains("[off]"); status.muted = line.contains("[off]");
break; break;

View file

@ -56,9 +56,6 @@ impl HudApp {
} }
}); });
// Start periodic updates for battery/volume
start_metrics_updates(state.clone());
// Subscribe to state changes // Subscribe to state changes
let window_clone = window.clone(); let window_clone = window.clone();
let state_clone = state.clone(); let state_clone = state.clone();
@ -727,9 +724,3 @@ fn run_event_loop(socket_path: PathBuf, state: SharedState) -> anyhow::Result<()
} }
}) })
} }
fn start_metrics_updates(_state: SharedState) {
// Battery and volume are now updated in the main UI loop
// This function could be used for more expensive operations
// that don't need to run as frequently
}

View file

@ -34,19 +34,17 @@ impl BatteryStatus {
let name_str = name.to_string_lossy(); let name_str = name.to_string_lossy();
// Check for battery // Check for battery
if name_str.starts_with("BAT") { if name_str.starts_with("BAT")
if let Some((percent, charging)) = read_battery_info(&path) { && let Some((percent, charging)) = read_battery_info(&path) {
status.percent = Some(percent); status.percent = Some(percent);
status.charging = charging; status.charging = charging;
} }
}
// Check for AC adapter // Check for AC adapter
if name_str.starts_with("AC") || name_str.contains("ADP") { if (name_str.starts_with("AC") || name_str.contains("ADP"))
if let Some(online) = read_ac_status(&path) { && let Some(online) = read_ac_status(&path) {
status.ac_connected = online; status.ac_connected = online;
} }
}
} }
} }
@ -70,6 +68,7 @@ impl BatteryStatus {
} }
/// Check if battery is critically low /// Check if battery is critically low
#[allow(dead_code)]
pub fn is_critical(&self) -> bool { pub fn is_critical(&self) -> bool {
matches!(self.percent, Some(p) if p < 10 && !self.charging) matches!(self.percent, Some(p) if p < 10 && !self.charging)
} }

View file

@ -11,7 +11,6 @@ mod volume;
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::Parser;
use gtk4::prelude::*;
use std::path::PathBuf; use std::path::PathBuf;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;

View file

@ -2,8 +2,7 @@
//! //!
//! The HUD subscribes to events from shepherdd and tracks session state. //! The HUD subscribes to events from shepherdd and tracks session state.
use chrono::Local; use shepherd_api::{Event, EventPayload, VolumeInfo, VolumeRestrictions, WarningSeverity};
use shepherd_api::{Event, EventPayload, SessionEndReason, VolumeInfo, VolumeRestrictions, WarningSeverity};
use shepherd_util::{EntryId, SessionId}; use shepherd_util::{EntryId, SessionId};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::watch; use tokio::sync::watch;
@ -21,6 +20,7 @@ pub enum SessionState {
entry_name: String, entry_name: String,
started_at: std::time::Instant, started_at: std::time::Instant,
time_limit_secs: Option<u64>, time_limit_secs: Option<u64>,
#[allow(dead_code)]
time_remaining_secs: Option<u64>, time_remaining_secs: Option<u64>,
}, },
@ -64,19 +64,6 @@ impl SessionState {
} }
} }
/// System metrics for display
#[derive(Debug, Clone, Default)]
pub struct SystemMetrics {
/// Battery percentage (0-100)
pub battery_percent: Option<u8>,
/// Whether battery is charging
pub battery_charging: bool,
/// Volume percentage (0-100)
pub volume_percent: Option<u8>,
/// Whether volume is muted
pub volume_muted: bool,
}
/// Shared state for the HUD /// Shared state for the HUD
#[derive(Clone)] #[derive(Clone)]
pub struct SharedState { pub struct SharedState {
@ -84,10 +71,6 @@ pub struct SharedState {
session_tx: Arc<watch::Sender<SessionState>>, session_tx: Arc<watch::Sender<SessionState>>,
/// Session state receiver /// Session state receiver
session_rx: watch::Receiver<SessionState>, session_rx: watch::Receiver<SessionState>,
/// System metrics sender
metrics_tx: Arc<watch::Sender<SystemMetrics>>,
/// System metrics receiver
metrics_rx: watch::Receiver<SystemMetrics>,
/// Volume info sender (updated via events, not polling) /// Volume info sender (updated via events, not polling)
volume_tx: Arc<watch::Sender<Option<VolumeInfo>>>, volume_tx: Arc<watch::Sender<Option<VolumeInfo>>>,
/// Volume info receiver /// Volume info receiver
@ -97,14 +80,11 @@ pub struct SharedState {
impl SharedState { impl SharedState {
pub fn new() -> Self { pub fn new() -> Self {
let (session_tx, session_rx) = watch::channel(SessionState::NoSession); let (session_tx, session_rx) = watch::channel(SessionState::NoSession);
let (metrics_tx, metrics_rx) = watch::channel(SystemMetrics::default());
let (volume_tx, volume_rx) = watch::channel(None); let (volume_tx, volume_rx) = watch::channel(None);
Self { Self {
session_tx: Arc::new(session_tx), session_tx: Arc::new(session_tx),
session_rx, session_rx,
metrics_tx: Arc::new(metrics_tx),
metrics_rx,
volume_tx: Arc::new(volume_tx), volume_tx: Arc::new(volume_tx),
volume_rx, volume_rx,
} }
@ -116,25 +96,16 @@ impl SharedState {
} }
/// Subscribe to session state changes /// Subscribe to session state changes
#[allow(dead_code)]
pub fn subscribe_session(&self) -> watch::Receiver<SessionState> { pub fn subscribe_session(&self) -> watch::Receiver<SessionState> {
self.session_rx.clone() self.session_rx.clone()
} }
/// Subscribe to metrics changes
pub fn subscribe_metrics(&self) -> watch::Receiver<SystemMetrics> {
self.metrics_rx.clone()
}
/// Update session state /// Update session state
pub fn set_session_state(&self, state: SessionState) { pub fn set_session_state(&self, state: SessionState) {
let _ = self.session_tx.send(state); let _ = self.session_tx.send(state);
} }
/// Update system metrics
pub fn set_metrics(&self, metrics: SystemMetrics) {
let _ = self.metrics_tx.send(metrics);
}
/// Get current volume info (cached from events) /// Get current volume info (cached from events)
pub fn volume_info(&self) -> Option<VolumeInfo> { pub fn volume_info(&self) -> Option<VolumeInfo> {
self.volume_rx.borrow().clone() self.volume_rx.borrow().clone()
@ -165,6 +136,7 @@ impl SharedState {
} }
/// Update time remaining for current session /// Update time remaining for current session
#[allow(dead_code)]
pub fn update_time_remaining(&self, remaining_secs: u64) { pub fn update_time_remaining(&self, remaining_secs: u64) {
self.session_tx.send_modify(|state| { self.session_tx.send_modify(|state| {
if let SessionState::Active { if let SessionState::Active {
@ -246,8 +218,7 @@ impl SharedState {
entry_name, entry_name,
.. ..
} = state } = state
{ && sid == session_id {
if sid == session_id {
*state = SessionState::Warning { *state = SessionState::Warning {
session_id: session_id.clone(), session_id: session_id.clone(),
entry_id: entry_id.clone(), entry_id: entry_id.clone(),
@ -258,7 +229,6 @@ impl SharedState {
severity: *severity, severity: *severity,
}; };
} }
}
}); });
} }
@ -275,11 +245,11 @@ impl SharedState {
if let Some(session) = &snapshot.current_session { if let Some(session) = &snapshot.current_session {
let now = shepherd_util::now(); let now = shepherd_util::now();
// For unlimited sessions (deadline=None), time_remaining is None // For unlimited sessions (deadline=None), time_remaining is None
let time_remaining = session.deadline.and_then(|d| { let time_remaining = session.deadline.map(|d| {
if d > now { if d > now {
Some((d - now).num_seconds().max(0) as u64) (d - now).num_seconds().max(0) as u64
} else { } else {
Some(0) 0
} }
}); });
self.set_session_state(SessionState::Active { self.set_session_state(SessionState::Active {

View file

@ -75,52 +75,6 @@ pub fn toggle_mute() -> anyhow::Result<()> {
}) })
} }
/// Increase volume by a step via shepherdd
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 shepherdd
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 shepherdd /// Set volume to a specific percentage via shepherdd
pub fn set_volume(percent: u8) -> anyhow::Result<()> { pub fn set_volume(percent: u8) -> anyhow::Result<()> {
let socket_path = get_socket_path(); let socket_path = get_socket_path();

View file

@ -158,7 +158,7 @@ impl IpcServer {
let client_id_clone = client_id.clone(); let client_id_clone = client_id.clone();
// Spawn reader task // Spawn reader task
let reader_handle = tokio::spawn(async move { let _reader_handle = tokio::spawn(async move {
let mut reader = BufReader::new(read_half); let mut reader = BufReader::new(read_half);
let mut line = String::new(); let mut line = String::new();
@ -235,8 +235,8 @@ impl IpcServer {
clients.get(&client_id_writer).map(|h| h.subscribed).unwrap_or(false) clients.get(&client_id_writer).map(|h| h.subscribed).unwrap_or(false)
}; };
if is_subscribed { if is_subscribed
if let Ok(json) = serde_json::to_string(&event) { && let Ok(json) = serde_json::to_string(&event) {
let mut msg = json; let mut msg = json;
msg.push('\n'); msg.push('\n');
if let Err(e) = writer.write_all(msg.as_bytes()).await { if let Err(e) = writer.write_all(msg.as_bytes()).await {
@ -244,7 +244,6 @@ impl IpcServer {
break; break;
} }
} }
}
} }
} }
} }

View file

@ -2,15 +2,13 @@
use gtk4::glib; use gtk4::glib;
use gtk4::prelude::*; use gtk4::prelude::*;
use std::cell::RefCell;
use std::path::PathBuf; use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{debug, error, info}; use tracing::{debug, error, info};
use crate::client::{ClientCommand, CommandClient, ServiceClient}; use crate::client::{CommandClient, ServiceClient};
use crate::grid::LauncherGrid; use crate::grid::LauncherGrid;
use crate::state::{LauncherState, SharedState}; use crate::state::{LauncherState, SharedState};
@ -166,7 +164,7 @@ impl LauncherApp {
let runtime = Arc::new(Runtime::new().expect("Failed to create tokio runtime")); let runtime = Arc::new(Runtime::new().expect("Failed to create tokio runtime"));
// Create command channel // Create command channel
let (command_tx, command_rx) = mpsc::unbounded_channel(); let (_command_tx, command_rx) = mpsc::unbounded_channel();
// Create command client for sending commands // Create command client for sending commands
let command_client = Arc::new(CommandClient::new(&socket_path)); let command_client = Arc::new(CommandClient::new(&socket_path));

View file

@ -1,19 +1,20 @@
//! IPC client wrapper for the launcher UI //! IPC client wrapper for the launcher UI
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use shepherd_api::{Command, Event, ReasonCode, Response, ResponsePayload, ResponseResult}; use shepherd_api::{Command, ReasonCode, Response, ResponsePayload, ResponseResult};
use shepherd_ipc::IpcClient; use shepherd_ipc::IpcClient;
use shepherd_util::EntryId; use shepherd_util::EntryId;
use std::path::Path; use std::path::Path;
use std::time::Duration; use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::time::sleep; use tokio::time::sleep;
use tracing::{debug, error, info, warn}; use tracing::{error, info, warn};
use crate::state::{LauncherState, SharedState}; use crate::state::{LauncherState, SharedState};
/// Messages from UI to client task /// Messages from UI to client task
#[derive(Debug)] #[derive(Debug)]
#[allow(dead_code)]
pub enum ClientCommand { pub enum ClientCommand {
/// Request to launch an entry /// Request to launch an entry
Launch(EntryId), Launch(EntryId),
@ -98,7 +99,7 @@ impl ServiceClient {
info!("Shutdown requested"); info!("Shutdown requested");
return Ok(()); return Ok(());
} }
ClientCommand::Launch(entry_id) => { ClientCommand::Launch(_entry_id) => {
// We can't send commands after subscribing since client is consumed // We can't send commands after subscribing since client is consumed
// Need to reconnect for commands // Need to reconnect for commands
warn!("Launch command received but cannot send after subscribe"); warn!("Launch command received but cannot send after subscribe");
@ -222,6 +223,7 @@ impl CommandClient {
}).await.map_err(Into::into) }).await.map_err(Into::into)
} }
#[allow(dead_code)]
pub async fn stop_current(&self) -> Result<Response> { pub async fn stop_current(&self) -> Result<Response> {
let mut client = IpcClient::connect(&self.socket_path).await?; let mut client = IpcClient::connect(&self.socket_path).await?;
client.send(Command::StopCurrent { client.send(Command::StopCurrent {
@ -234,6 +236,7 @@ impl CommandClient {
client.send(Command::GetState).await.map_err(Into::into) client.send(Command::GetState).await.map_err(Into::into)
} }
#[allow(dead_code)]
pub async fn list_entries(&self) -> Result<Response> { pub async fn list_entries(&self) -> Result<Response> {
let mut client = IpcClient::connect(&self.socket_path).await?; let mut client = IpcClient::connect(&self.socket_path).await?;
client.send(Command::ListEntries { at_time: None }).await.map_err(Into::into) client.send(Command::ListEntries { at_time: None }).await.map_err(Into::into)

View file

@ -13,10 +13,12 @@ use crate::tile::LauncherTile;
mod imp { mod imp {
use super::*; use super::*;
type LaunchCallback = Rc<RefCell<Option<Box<dyn Fn(EntryId) + 'static>>>>;
pub struct LauncherGrid { pub struct LauncherGrid {
pub flow_box: gtk4::FlowBox, pub flow_box: gtk4::FlowBox,
pub tiles: RefCell<Vec<LauncherTile>>, pub tiles: RefCell<Vec<LauncherTile>>,
pub on_launch: Rc<RefCell<Option<Box<dyn Fn(EntryId) + 'static>>>>, pub on_launch: LaunchCallback,
} }
impl Default for LauncherGrid { impl Default for LauncherGrid {
@ -114,11 +116,10 @@ impl LauncherGrid {
// Connect click handler // Connect click handler
let on_launch = imp.on_launch.clone(); let on_launch = imp.on_launch.clone();
tile.connect_clicked(move |tile| { tile.connect_clicked(move |tile| {
if let Some(entry_id) = tile.entry_id() { if let Some(entry_id) = tile.entry_id()
if let Some(callback) = on_launch.borrow().as_ref() { && let Some(callback) = on_launch.borrow().as_ref() {
callback(entry_id); callback(entry_id);
} }
}
}); });
imp.flow_box.insert(&tile, -1); imp.flow_box.insert(&tile, -1);

View file

@ -11,7 +11,6 @@ mod tile;
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::Parser;
use gtk4::prelude::*;
use std::path::PathBuf; use std::path::PathBuf;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;

View file

@ -2,37 +2,36 @@
use shepherd_api::{ServiceStateSnapshot, EntryView, Event, EventPayload}; use shepherd_api::{ServiceStateSnapshot, EntryView, Event, EventPayload};
use shepherd_util::SessionId; use shepherd_util::SessionId;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::sync::watch; use tokio::sync::watch;
/// Current state of the launcher UI /// Current state of the launcher UI
#[derive(Debug, Clone)] #[derive(Debug, Clone, Default)]
pub enum LauncherState { pub enum LauncherState {
/// Not connected to shepherdd /// Not connected to shepherdd
#[default]
Disconnected, Disconnected,
/// Connected, waiting for initial state /// Connected, waiting for initial state
Connecting, Connecting,
/// Connected, no session running - show grid /// Connected, no session running - show grid
Idle { entries: Vec<EntryView> }, Idle { entries: Vec<EntryView> },
/// Launch requested, waiting for response /// Launch requested, waiting for response
Launching { entry_id: String }, Launching {
#[allow(dead_code)]
entry_id: String
},
/// Session is running /// Session is running
SessionActive { SessionActive {
#[allow(dead_code)]
session_id: SessionId, session_id: SessionId,
entry_label: String, entry_label: String,
#[allow(dead_code)]
time_remaining: Option<Duration>, time_remaining: Option<Duration>,
}, },
/// Error state /// Error state
Error { message: String }, Error { message: String },
} }
impl Default for LauncherState {
fn default() -> Self {
Self::Disconnected
}
}
/// Shared state container /// Shared state container
#[derive(Clone)] #[derive(Clone)]
pub struct SharedState { pub struct SharedState {

View file

@ -1,6 +1,6 @@
//! SQLite-based store implementation //! SQLite-based store implementation
use chrono::{DateTime, Local, NaiveDate, TimeZone}; use chrono::{DateTime, Local, NaiveDate};
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
use shepherd_util::EntryId; use shepherd_util::EntryId;
use std::path::Path; use std::path::Path;
@ -8,7 +8,7 @@ use std::sync::Mutex;
use std::time::Duration; use std::time::Duration;
use tracing::{debug, warn}; use tracing::{debug, warn};
use crate::{AuditEvent, SessionSnapshot, StateSnapshot, Store, StoreError, StoreResult}; use crate::{AuditEvent, StateSnapshot, Store, StoreResult};
/// SQLite-based store /// SQLite-based store
pub struct SqliteStore { pub struct SqliteStore {

View file

@ -12,22 +12,21 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::Parser; use clap::Parser;
use shepherd_api::{ use shepherd_api::{
Command, ServiceStateSnapshot, ErrorCode, ErrorInfo, Event, EventPayload, HealthStatus, Command, ErrorCode, ErrorInfo, Event, EventPayload, HealthStatus,
Response, ResponsePayload, SessionEndReason, StopMode, VolumeInfo, VolumeRestrictions, Response, ResponsePayload, SessionEndReason, StopMode, VolumeInfo, VolumeRestrictions,
API_VERSION,
}; };
use shepherd_config::{load_config, Policy, VolumePolicy}; use shepherd_config::{load_config, VolumePolicy};
use shepherd_core::{CoreEngine, CoreEvent, LaunchDecision, StopDecision}; use shepherd_core::{CoreEngine, CoreEvent, LaunchDecision, StopDecision};
use shepherd_host_api::{HostAdapter, HostEvent, StopMode as HostStopMode, VolumeController}; use shepherd_host_api::{HostAdapter, HostEvent, StopMode as HostStopMode, VolumeController};
use shepherd_host_linux::{LinuxHost, LinuxVolumeController}; 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, MonotonicInstant, RateLimiter};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::{debug, error, info, warn, Level}; use tracing::{debug, error, info, warn};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
/// shepherdd - Policy enforcement service for child-focused computing /// shepherdd - Policy enforcement service for child-focused computing
@ -243,8 +242,8 @@ impl Service {
.and_then(|s| s.host_handle.clone()) .and_then(|s| s.host_handle.clone())
}; };
if let Some(handle) = handle { if let Some(handle) = handle
if let Err(e) = host && let Err(e) = host
.stop( .stop(
&handle, &handle,
HostStopMode::Graceful { HostStopMode::Graceful {
@ -256,7 +255,6 @@ impl Service {
warn!(error = %e, "Failed to stop session gracefully, forcing"); warn!(error = %e, "Failed to stop session gracefully, forcing");
let _ = host.stop(&handle, HostStopMode::Force).await; let _ = host.stop(&handle, HostStopMode::Force).await;
} }
}
ipc.broadcast_event(Event::new(EventPayload::SessionExpiring { ipc.broadcast_event(Event::new(EventPayload::SessionExpiring {
session_id: session_id.clone(), session_id: session_id.clone(),
@ -336,13 +334,12 @@ impl Service {
info!(has_event = core_event.is_some(), "notify_session_exited result"); info!(has_event = core_event.is_some(), "notify_session_exited result");
if let Some(event) = core_event { if let Some(CoreEvent::SessionEnded {
if let CoreEvent::SessionEnded { session_id,
session_id, entry_id,
entry_id, reason,
reason, duration,
duration, }) = core_event
} = event
{ {
info!( info!(
session_id = %session_id, session_id = %session_id,
@ -366,7 +363,6 @@ impl Service {
info!("Broadcasting StateChanged"); info!("Broadcasting StateChanged");
ipc.broadcast_event(Event::new(EventPayload::StateChanged(state))); ipc.broadcast_event(Event::new(EventPayload::StateChanged(state)));
} }
}
} }
HostEvent::WindowReady { handle } => { HostEvent::WindowReady { handle } => {
@ -443,6 +439,7 @@ impl Service {
} }
} }
#[allow(clippy::too_many_arguments)]
async fn handle_command( async fn handle_command(
engine: &Arc<Mutex<CoreEngine>>, engine: &Arc<Mutex<CoreEngine>>,
host: &Arc<LinuxHost>, host: &Arc<LinuxHost>,
@ -530,13 +527,12 @@ impl Service {
Err(e) => { Err(e) => {
// Notify session ended with error and broadcast to subscribers // Notify session ended with error and broadcast to subscribers
let mut eng = engine.lock().await; let mut eng = engine.lock().await;
if let Some(event) = eng.notify_session_exited(Some(-1), now_mono, now) { if let Some(CoreEvent::SessionEnded {
if let CoreEvent::SessionEnded { session_id,
session_id, entry_id,
entry_id, reason,
reason, duration,
duration, }) = eng.notify_session_exited(Some(-1), now_mono, now)
} = event
{ {
ipc.broadcast_event(Event::new(EventPayload::SessionEnded { ipc.broadcast_event(Event::new(EventPayload::SessionEnded {
session_id, session_id,
@ -549,7 +545,6 @@ impl Service {
let state = eng.get_state(); let state = eng.get_state();
ipc.broadcast_event(Event::new(EventPayload::StateChanged(state))); ipc.broadcast_event(Event::new(EventPayload::StateChanged(state)));
} }
}
Response::error( Response::error(
request_id, request_id,
@ -629,14 +624,13 @@ impl Service {
Command::ReloadConfig => { Command::ReloadConfig => {
// Check permission // Check permission
if let Some(info) = ipc.get_client_info(client_id).await { if let Some(info) = ipc.get_client_info(client_id).await
if !info.role.can_reload_config() { && !info.role.can_reload_config() {
return Response::error( return Response::error(
request_id, request_id,
ErrorInfo::new(ErrorCode::PermissionDenied, "Admin role required"), ErrorInfo::new(ErrorCode::PermissionDenied, "Admin role required"),
); );
} }
}
// TODO: Reload from original config path // TODO: Reload from original config path
Response::error( Response::error(
@ -672,14 +666,13 @@ impl Service {
Command::ExtendCurrent { by } => { Command::ExtendCurrent { by } => {
// Check permission // Check permission
if let Some(info) = ipc.get_client_info(client_id).await { if let Some(info) = ipc.get_client_info(client_id).await
if !info.role.can_extend() { && !info.role.can_extend() {
return Response::error( return Response::error(
request_id, request_id,
ErrorInfo::new(ErrorCode::PermissionDenied, "Admin role required"), ErrorInfo::new(ErrorCode::PermissionDenied, "Admin role required"),
); );
} }
}
let mut eng = engine.lock().await; let mut eng = engine.lock().await;
match eng.extend_current(by, now_mono, now) { match eng.extend_current(by, now_mono, now) {
@ -694,7 +687,7 @@ impl Service {
} }
Command::GetVolume => { Command::GetVolume => {
let restrictions = Self::get_current_volume_restrictions(&engine).await; let restrictions = Self::get_current_volume_restrictions(engine).await;
match volume.get_status().await { match volume.get_status().await {
Ok(status) => { Ok(status) => {
@ -722,7 +715,7 @@ impl Service {
} }
Command::SetVolume { percent } => { Command::SetVolume { percent } => {
let restrictions = Self::get_current_volume_restrictions(&engine).await; let restrictions = Self::get_current_volume_restrictions(engine).await;
if !restrictions.allow_change { if !restrictions.allow_change {
return Response::success( return Response::success(
@ -755,80 +748,8 @@ impl Service {
} }
} }
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 => { Command::ToggleMute => {
let restrictions = Self::get_current_volume_restrictions(&engine).await; let restrictions = Self::get_current_volume_restrictions(engine).await;
if !restrictions.allow_mute { if !restrictions.allow_mute {
return Response::success( return Response::success(
@ -859,7 +780,7 @@ impl Service {
} }
Command::SetMute { muted } => { Command::SetMute { muted } => {
let restrictions = Self::get_current_volume_restrictions(&engine).await; let restrictions = Self::get_current_volume_restrictions(engine).await;
if !restrictions.allow_mute { if !restrictions.allow_mute {
return Response::success( return Response::success(
@ -900,13 +821,11 @@ impl Service {
let eng = engine.lock().await; let eng = engine.lock().await;
// Check if there's an active session with volume restrictions // Check if there's an active session with volume restrictions
if let Some(session) = eng.current_session() { if let Some(session) = eng.current_session()
if let Some(entry) = eng.policy().get_entry(&session.plan.entry_id) { && let Some(entry) = eng.policy().get_entry(&session.plan.entry_id)
if let Some(ref vol_policy) = entry.volume { && let Some(ref vol_policy) = entry.volume {
return Self::convert_volume_policy(vol_policy); return Self::convert_volume_policy(vol_policy);
}
} }
}
// Fall back to global policy // Fall back to global policy
Self::convert_volume_policy(&eng.policy().volume) Self::convert_volume_policy(&eng.policy().volume)