//! Launcher application state management use shepherd_api::{EntryView, Event, EventPayload, ServiceStateSnapshot}; use shepherd_util::SessionId; use std::time::Duration; use tokio::sync::watch; /// Current state of the launcher UI #[derive(Debug, Clone, Default)] pub enum LauncherState { /// Not connected to shepherdd #[default] Disconnected, /// Connected, waiting for initial state Connecting, /// Connected, no session running - show grid Idle { entries: Vec }, /// Launch requested, waiting for response Launching { #[allow(dead_code)] entry_id: String, }, /// Session is running SessionActive { #[allow(dead_code)] session_id: SessionId, entry_label: String, #[allow(dead_code)] time_remaining: Option, }, /// Error state Error { message: String }, } /// Shared state container #[derive(Clone)] pub struct SharedState { sender: watch::Sender, receiver: watch::Receiver, } impl SharedState { pub fn new() -> Self { let (sender, receiver) = watch::channel(LauncherState::default()); Self { sender, receiver } } pub fn set(&self, state: LauncherState) { let _ = self.sender.send(state); } pub fn get(&self) -> LauncherState { self.receiver.borrow().clone() } pub fn subscribe(&self) -> watch::Receiver { self.receiver.clone() } /// Update state from shepherdd event pub fn handle_event(&self, event: Event) { tracing::info!(event = ?event.payload, "Received event from shepherdd"); match event.payload { EventPayload::StateChanged(snapshot) => { tracing::info!( has_session = snapshot.current_session.is_some(), "Applying state snapshot" ); self.apply_snapshot(snapshot); } EventPayload::SessionStarted { session_id, entry_id: _, label, deadline, } => { tracing::info!(session_id = %session_id, label = %label, "Session started event"); let now = shepherd_util::now(); // For unlimited sessions (deadline=None), time_remaining is None let time_remaining = deadline.and_then(|d| { if d > now { (d - now).to_std().ok() } else { Some(Duration::ZERO) } }); self.set(LauncherState::SessionActive { session_id, entry_label: label, time_remaining, }); } EventPayload::SessionEnded { session_id, entry_id, reason, .. } => { tracing::info!(session_id = %session_id, entry_id = %entry_id, reason = ?reason, "Session ended event - setting Connecting"); // Will be followed by StateChanged, but set to connecting // to ensure grid reloads self.set(LauncherState::Connecting); } EventPayload::SessionExpiring { .. } => { // Time's up indicator handled by HUD } EventPayload::WarningIssued { .. } => { // Warnings handled by HUD } EventPayload::PolicyReloaded { .. } => { // Request fresh state self.set(LauncherState::Connecting); } EventPayload::EntryAvailabilityChanged { .. } => { // Request fresh state self.set(LauncherState::Connecting); } EventPayload::Shutdown => { // Service is shutting down self.set(LauncherState::Disconnected); } EventPayload::AuditEntry { .. } => { // Audit events are for admin clients, ignore } EventPayload::VolumeChanged { .. } => { // Volume events are handled by HUD } } } fn apply_snapshot(&self, snapshot: ServiceStateSnapshot) { if let Some(session) = snapshot.current_session { let now = shepherd_util::now(); // For unlimited sessions (deadline=None), time_remaining is None let time_remaining = session.deadline.and_then(|d| { if d > now { (d - now).to_std().ok() } else { Some(Duration::ZERO) } }); self.set(LauncherState::SessionActive { session_id: session.session_id, entry_label: session.label, time_remaining, }); } else { self.set(LauncherState::Idle { entries: snapshot.entries, }); } } } impl Default for SharedState { fn default() -> Self { Self::new() } }