diff --git a/config.example.toml b/config.example.toml index b86eee8..90f54ca 100644 --- a/config.example.toml +++ b/config.example.toml @@ -180,9 +180,8 @@ label = "Celeste" icon = "~/Games/Icons/Celeste.png" [entries.kind] -type = "snap" -snap_name = "steam" -args = ["steam://rungameid/504230"] # Steam App ID (passed to 'snap run steam') +type = "steam" +app_id = 504230 # Steam App ID [entries.availability] [[entries.availability.windows]] @@ -206,9 +205,8 @@ label = "A Short Hike" icon = "~/Games/Icons/A_Short_Hike.png" [entries.kind] -type = "snap" -snap_name = "steam" -args = ["steam://rungameid/1055540"] # Steam App ID (passed to 'snap run steam') +type = "steam" +app_id = 1055540 # Steam App ID [entries.availability] [[entries.availability.windows]] diff --git a/crates/shepherd-api/src/types.rs b/crates/shepherd-api/src/types.rs index 20bb6df..fe43fc4 100644 --- a/crates/shepherd-api/src/types.rs +++ b/crates/shepherd-api/src/types.rs @@ -13,6 +13,7 @@ use std::time::Duration; pub enum EntryKindTag { Process, Snap, + Steam, Flatpak, Vm, Media, @@ -47,6 +48,17 @@ pub enum EntryKind { #[serde(default)] env: HashMap, }, + /// Steam game launched via the Steam snap (Linux) + Steam { + /// Steam App ID (e.g., 504230 for Celeste) + app_id: u32, + /// Additional command-line arguments passed to Steam + #[serde(default)] + args: Vec, + /// Additional environment variables + #[serde(default)] + env: HashMap, + }, /// Flatpak application - uses systemd scope-based process management Flatpak { /// The Flatpak application ID (e.g., "org.prismlauncher.PrismLauncher") @@ -79,6 +91,7 @@ impl EntryKind { match self { EntryKind::Process { .. } => EntryKindTag::Process, EntryKind::Snap { .. } => EntryKindTag::Snap, + EntryKind::Steam { .. } => EntryKindTag::Steam, EntryKind::Flatpak { .. } => EntryKindTag::Flatpak, EntryKind::Vm { .. } => EntryKindTag::Vm, EntryKind::Media { .. } => EntryKindTag::Media, diff --git a/crates/shepherd-config/README.md b/crates/shepherd-config/README.md index 6c3c809..30450e2 100644 --- a/crates/shepherd-config/README.md +++ b/crates/shepherd-config/README.md @@ -110,6 +110,9 @@ kind = { type = "process", command = "/usr/bin/game", args = ["--fullscreen"] } # Snap application kind = { type = "snap", snap_name = "mc-installer" } +# Steam game (via Steam snap) +kind = { type = "steam", app_id = 504230 } + # Virtual machine (future) kind = { type = "vm", driver = "qemu", args = { disk = "game.qcow2" } } diff --git a/crates/shepherd-config/src/bin/validate-config.rs b/crates/shepherd-config/src/bin/validate-config.rs index 7d12688..7fd579f 100644 --- a/crates/shepherd-config/src/bin/validate-config.rs +++ b/crates/shepherd-config/src/bin/validate-config.rs @@ -54,6 +54,9 @@ fn main() -> ExitCode { EntryKind::Snap { snap_name, .. } => { format!("snap ({})", snap_name) } + EntryKind::Steam { app_id, .. } => { + format!("steam ({})", app_id) + } EntryKind::Flatpak { app_id, .. } => { format!("flatpak ({})", app_id) } diff --git a/crates/shepherd-config/src/policy.rs b/crates/shepherd-config/src/policy.rs index ce6d113..62dfd27 100644 --- a/crates/shepherd-config/src/policy.rs +++ b/crates/shepherd-config/src/policy.rs @@ -256,6 +256,7 @@ fn convert_entry_kind(raw: RawEntryKind) -> EntryKind { match raw { RawEntryKind::Process { command, args, env, cwd } => EntryKind::Process { command, args, env, cwd }, RawEntryKind::Snap { snap_name, command, args, env } => EntryKind::Snap { snap_name, command, args, env }, + RawEntryKind::Steam { app_id, args, env } => EntryKind::Steam { app_id, args, env }, RawEntryKind::Flatpak { app_id, args, env } => EntryKind::Flatpak { app_id, args, env }, RawEntryKind::Vm { driver, args } => EntryKind::Vm { driver, args }, RawEntryKind::Media { library_id, args } => EntryKind::Media { library_id, args }, diff --git a/crates/shepherd-config/src/schema.rs b/crates/shepherd-config/src/schema.rs index a68b4d1..6ee6aac 100644 --- a/crates/shepherd-config/src/schema.rs +++ b/crates/shepherd-config/src/schema.rs @@ -116,6 +116,17 @@ pub enum RawEntryKind { #[serde(default)] env: HashMap, }, + /// Steam game launched via the Steam snap (Linux) + Steam { + /// Steam App ID (e.g., 504230 for Celeste) + app_id: u32, + /// Additional command-line arguments passed to Steam + #[serde(default)] + args: Vec, + /// Additional environment variables + #[serde(default)] + env: HashMap, + }, /// Flatpak application - uses systemd scope-based process management Flatpak { /// The Flatpak application ID (e.g., "org.prismlauncher.PrismLauncher") diff --git a/crates/shepherd-config/src/validation.rs b/crates/shepherd-config/src/validation.rs index 27dc3de..3c27645 100644 --- a/crates/shepherd-config/src/validation.rs +++ b/crates/shepherd-config/src/validation.rs @@ -71,6 +71,14 @@ fn validate_entry(entry: &RawEntry, config: &RawConfig) -> Vec }); } } + RawEntryKind::Steam { app_id, .. } => { + if *app_id == 0 { + errors.push(ValidationError::EntryError { + entry_id: entry.id.clone(), + message: "app_id must be > 0".into(), + }); + } + } RawEntryKind::Flatpak { app_id, .. } => { if app_id.is_empty() { errors.push(ValidationError::EntryError { diff --git a/crates/shepherd-host-api/README.md b/crates/shepherd-host-api/README.md index 600c3ad..c608922 100644 --- a/crates/shepherd-host-api/README.md +++ b/crates/shepherd-host-api/README.md @@ -30,6 +30,7 @@ let caps = host.capabilities(); // Check supported entry kinds if caps.supports_kind(EntryKindTag::Process) { /* ... */ } if caps.supports_kind(EntryKindTag::Snap) { /* ... */ } +if caps.supports_kind(EntryKindTag::Steam) { /* ... */ } // Check enforcement capabilities if caps.can_kill_forcefully { /* Can use SIGKILL/TerminateProcess */ } diff --git a/crates/shepherd-host-api/src/capabilities.rs b/crates/shepherd-host-api/src/capabilities.rs index 8bfed89..d13c493 100644 --- a/crates/shepherd-host-api/src/capabilities.rs +++ b/crates/shepherd-host-api/src/capabilities.rs @@ -59,6 +59,7 @@ impl HostCapabilities { let mut spawn_kinds = HashSet::new(); spawn_kinds.insert(EntryKindTag::Process); spawn_kinds.insert(EntryKindTag::Snap); + spawn_kinds.insert(EntryKindTag::Steam); spawn_kinds.insert(EntryKindTag::Flatpak); spawn_kinds.insert(EntryKindTag::Vm); spawn_kinds.insert(EntryKindTag::Media); diff --git a/crates/shepherd-host-linux/README.md b/crates/shepherd-host-linux/README.md index 29af682..405df61 100644 --- a/crates/shepherd-host-linux/README.md +++ b/crates/shepherd-host-linux/README.md @@ -90,6 +90,21 @@ let entry_kind = EntryKind::Snap { let handle = host.spawn(session_id, &entry_kind, options).await?; ``` +### Spawning Steam Games + +Steam games are launched via the Steam snap: + +```rust +let entry_kind = EntryKind::Steam { + app_id: 504230, + args: vec![], + env: Default::default(), +}; + +// Spawns via: snap run steam steam://rungameid/504230 +let handle = host.spawn(session_id, &entry_kind, options).await?; +``` + ### Stopping Sessions ```rust diff --git a/crates/shepherd-host-linux/src/adapter.rs b/crates/shepherd-host-linux/src/adapter.rs index 43b8e06..62b9ad2 100644 --- a/crates/shepherd-host-linux/src/adapter.rs +++ b/crates/shepherd-host-linux/src/adapter.rs @@ -3,17 +3,20 @@ use async_trait::async_trait; use shepherd_api::EntryKind; use shepherd_host_api::{ - HostAdapter, HostCapabilities, HostError, HostEvent, HostHandlePayload, + ExitStatus, HostAdapter, HostCapabilities, HostError, HostEvent, HostHandlePayload, HostResult, HostSessionHandle, SpawnOptions, StopMode, }; use shepherd_util::SessionId; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::sync::mpsc; use tracing::{info, warn}; -use crate::process::{init, kill_by_command, kill_flatpak_cgroup, kill_snap_cgroup, ManagedProcess}; +use crate::process::{ + find_steam_game_pids, init, kill_by_command, kill_flatpak_cgroup, kill_snap_cgroup, + kill_steam_game_processes, ManagedProcess, +}; /// Expand `~` at the beginning of a path to the user's home directory fn expand_tilde(path: &str) -> String { @@ -40,6 +43,15 @@ struct SessionInfo { command_name: String, snap_name: Option, flatpak_app_id: Option, + steam_app_id: Option, +} + +#[derive(Clone, Debug)] +struct SteamSession { + pid: u32, + pgid: u32, + app_id: u32, + seen_game: bool, } /// Linux host adapter @@ -48,6 +60,7 @@ pub struct LinuxHost { processes: Arc>>, /// Track session info for killing session_info: Arc>>, + steam_sessions: Arc>>, event_tx: mpsc::UnboundedSender, event_rx: Arc>>>, } @@ -63,6 +76,7 @@ impl LinuxHost { capabilities: HostCapabilities::linux_full(), processes: Arc::new(Mutex::new(HashMap::new())), session_info: Arc::new(Mutex::new(HashMap::new())), + steam_sessions: Arc::new(Mutex::new(HashMap::new())), event_tx: tx, event_rx: Arc::new(Mutex::new(Some(rx))), } @@ -71,6 +85,7 @@ impl LinuxHost { /// Start the background process monitor pub fn start_monitor(&self) -> tokio::task::JoinHandle<()> { let processes = self.processes.clone(); + let steam_sessions = self.steam_sessions.clone(); let event_tx = self.event_tx.clone(); tokio::spawn(async move { @@ -78,13 +93,22 @@ impl LinuxHost { tokio::time::sleep(Duration::from_millis(100)).await; let mut exited = Vec::new(); + let steam_pids: HashSet = { + steam_sessions + .lock() + .unwrap() + .keys() + .cloned() + .collect() + }; { let mut procs = processes.lock().unwrap(); for (pid, proc) in procs.iter_mut() { match proc.try_wait() { Ok(Some(status)) => { - exited.push((*pid, proc.pgid, status)); + let is_steam = steam_pids.contains(pid); + exited.push((*pid, proc.pgid, status, is_steam)); } Ok(None) => {} Err(e) => { @@ -93,12 +117,16 @@ impl LinuxHost { } } - for (pid, _, _) in &exited { + for (pid, _, _, _) in &exited { procs.remove(pid); } } - for (pid, pgid, status) in exited { + for (pid, pgid, status, is_steam) in exited { + if is_steam { + info!(pid = pid, pgid = pgid, status = ?status, "Steam launch process exited"); + continue; + } info!(pid = pid, pgid = pgid, status = ?status, "Process exited - sending HostEvent::Exited"); // We don't have the session_id here, so we use a placeholder @@ -110,6 +138,50 @@ impl LinuxHost { let _ = event_tx.send(HostEvent::Exited { handle, status }); } + + // Track Steam sessions by Steam App ID instead of process exit + let steam_snapshot: Vec = { + steam_sessions + .lock() + .unwrap() + .values() + .cloned() + .collect() + }; + + let mut ended = Vec::new(); + + for session in &steam_snapshot { + let has_game = !find_steam_game_pids(session.app_id).is_empty(); + if has_game { + if let Some(mut map) = steam_sessions.lock().ok() { + if let Some(entry) = map.get_mut(&session.pid) { + entry.seen_game = true; + } + } + } else if session.seen_game { + ended.push((session.pid, session.pgid)); + } + } + + if !ended.is_empty() { + let mut map = steam_sessions.lock().unwrap(); + let mut procs = processes.lock().unwrap(); + + for (pid, pgid) in ended { + map.remove(&pid); + procs.remove(&pid); + + let handle = HostSessionHandle::new( + SessionId::new(), + HostHandlePayload::Linux { pid, pgid }, + ); + let _ = event_tx.send(HostEvent::Exited { + handle, + status: ExitStatus::success(), + }); + } + } } }) } @@ -133,15 +205,15 @@ impl HostAdapter for LinuxHost { entry_kind: &EntryKind, options: SpawnOptions, ) -> HostResult { - // Extract argv, env, cwd, snap_name, and flatpak_app_id based on entry kind - let (argv, env, cwd, snap_name, flatpak_app_id) = match entry_kind { + // Extract argv, env, cwd, snap_name, flatpak_app_id, and steam_app_id based on entry kind + let (argv, env, cwd, snap_name, flatpak_app_id, steam_app_id) = match entry_kind { EntryKind::Process { command, args, env, cwd } => { let mut argv = vec![expand_tilde(command)]; argv.extend(expand_args(args)); let expanded_cwd = cwd.as_ref().map(|c| { std::path::PathBuf::from(expand_tilde(&c.to_string_lossy())) }); - (argv, env.clone(), expanded_cwd, None, None) + (argv, env.clone(), expanded_cwd, None, None, None) } EntryKind::Snap { snap_name, command, args, env } => { // For snap apps, we need to use 'snap run ' to launch them. @@ -154,13 +226,24 @@ impl HostAdapter for LinuxHost { argv.push(cmd.clone()); } argv.extend(expand_args(args)); - (argv, env.clone(), None, Some(snap_name.clone()), None) + (argv, env.clone(), None, Some(snap_name.clone()), None, None) + } + EntryKind::Steam { app_id, args, env } => { + // Steam games are launched via the Steam snap: snap run steam steam://rungameid/ + let mut argv = vec![ + "snap".to_string(), + "run".to_string(), + "steam".to_string(), + format!("steam://rungameid/{}", app_id), + ]; + argv.extend(expand_args(args)); + (argv, env.clone(), None, None, None, Some(*app_id)) } EntryKind::Flatpak { app_id, args, env } => { // For Flatpak apps, we use 'flatpak run ' to launch them. let mut argv = vec!["flatpak".to_string(), "run".to_string(), app_id.clone()]; argv.extend(expand_args(args)); - (argv, env.clone(), None, None, Some(app_id.clone())) + (argv, env.clone(), None, None, Some(app_id.clone()), None) } EntryKind::Vm { driver, args } => { // Construct command line from VM driver @@ -173,13 +256,13 @@ impl HostAdapter for LinuxHost { argv.push(value.to_string()); } } - (argv, HashMap::new(), None, None, None) + (argv, HashMap::new(), None, None, None, None) } EntryKind::Media { library_id, args: _ } => { // For media, we'd typically launch a media player // This is a placeholder - real implementation would integrate with a player let argv = vec!["xdg-open".to_string(), expand_tilde(library_id)]; - (argv, HashMap::new(), None, None, None) + (argv, HashMap::new(), None, None, None, None) } EntryKind::Custom { type_name: _, payload: _ } => { return Err(HostError::UnsupportedKind); @@ -190,6 +273,8 @@ impl HostAdapter for LinuxHost { // For snap/flatpak apps, use the app name (not "snap"/"flatpak") to avoid killing unrelated processes let command_name = if let Some(ref snap) = snap_name { snap.clone() + } else if steam_app_id.is_some() { + "steam".to_string() } else if let Some(ref app_id) = flatpak_app_id { app_id.clone() } else { @@ -215,6 +300,7 @@ impl HostAdapter for LinuxHost { command_name: command_name.clone(), snap_name: snap_name.clone(), flatpak_app_id: flatpak_app_id.clone(), + steam_app_id, }; self.session_info.lock().unwrap().insert(session_id.clone(), session_info_entry); info!(session_id = %session_id, command = %command_name, snap = ?snap_name, flatpak = ?flatpak_app_id, "Tracking session info"); @@ -226,6 +312,18 @@ impl HostAdapter for LinuxHost { self.processes.lock().unwrap().insert(pid, proc); + if let Some(app_id) = steam_app_id { + self.steam_sessions.lock().unwrap().insert( + pid, + SteamSession { + pid, + pgid, + app_id, + seen_game: false, + }, + ); + } + info!(pid = pid, pgid = pgid, "Spawned process"); Ok(handle) @@ -256,6 +354,14 @@ impl HostAdapter for LinuxHost { if let Some(ref snap) = info.snap_name { kill_snap_cgroup(snap, nix::sys::signal::Signal::SIGTERM); info!(snap = %snap, "Sent SIGTERM via snap cgroup"); + } else if let Some(app_id) = info.steam_app_id { + let _ = kill_steam_game_processes(app_id, nix::sys::signal::Signal::SIGTERM); + if let Some(mut map) = self.steam_sessions.lock().ok() { + if let Some(entry) = map.get_mut(&pid) { + entry.seen_game = true; + } + } + info!(steam_app_id = app_id, "Sent SIGTERM to Steam game processes"); } else if let Some(ref app_id) = info.flatpak_app_id { kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGTERM); info!(flatpak = %app_id, "Sent SIGTERM via flatpak cgroup"); @@ -265,9 +371,10 @@ impl HostAdapter for LinuxHost { info!(command = %info.command_name, "Sent SIGTERM via command name"); } } - - // Also send SIGTERM via process handle - { + + // Also send SIGTERM via process handle (skip for Steam sessions) + let is_steam = session_info.as_ref().and_then(|info| info.steam_app_id).is_some(); + if !is_steam { let procs = self.processes.lock().unwrap(); if let Some(p) = procs.get(&pid) { let _ = p.terminate(); @@ -283,6 +390,9 @@ impl HostAdapter for LinuxHost { if let Some(ref snap) = info.snap_name { kill_snap_cgroup(snap, nix::sys::signal::Signal::SIGKILL); info!(snap = %snap, "Sent SIGKILL via snap cgroup (timeout)"); + } else if let Some(app_id) = info.steam_app_id { + let _ = kill_steam_game_processes(app_id, nix::sys::signal::Signal::SIGKILL); + info!(steam_app_id = app_id, "Sent SIGKILL to Steam game processes (timeout)"); } else if let Some(ref app_id) = info.flatpak_app_id { kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGKILL); info!(flatpak = %app_id, "Sent SIGKILL via flatpak cgroup (timeout)"); @@ -291,17 +401,26 @@ impl HostAdapter for LinuxHost { info!(command = %info.command_name, "Sent SIGKILL via command name (timeout)"); } } - - // Also force kill via process handle - let procs = self.processes.lock().unwrap(); - if let Some(p) = procs.get(&pid) { - let _ = p.kill(); + + // Also force kill via process handle (skip for Steam sessions) + if !is_steam { + let procs = self.processes.lock().unwrap(); + if let Some(p) = procs.get(&pid) { + let _ = p.kill(); + } } break; } // Check if process is still running - let still_running = self.processes.lock().unwrap().contains_key(&pid); + let still_running = if is_steam { + let app_id = session_info.as_ref().and_then(|info| info.steam_app_id); + app_id + .map(|id| !find_steam_game_pids(id).is_empty()) + .unwrap_or(false) + } else { + self.processes.lock().unwrap().contains_key(&pid) + }; if !still_running { break; @@ -316,6 +435,14 @@ impl HostAdapter for LinuxHost { if let Some(ref snap) = info.snap_name { kill_snap_cgroup(snap, nix::sys::signal::Signal::SIGKILL); info!(snap = %snap, "Sent SIGKILL via snap cgroup"); + } else if let Some(app_id) = info.steam_app_id { + let _ = kill_steam_game_processes(app_id, nix::sys::signal::Signal::SIGKILL); + if let Some(mut map) = self.steam_sessions.lock().ok() { + if let Some(entry) = map.get_mut(&pid) { + entry.seen_game = true; + } + } + info!(steam_app_id = app_id, "Sent SIGKILL to Steam game processes"); } else if let Some(ref app_id) = info.flatpak_app_id { kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGKILL); info!(flatpak = %app_id, "Sent SIGKILL via flatpak cgroup"); @@ -324,11 +451,14 @@ impl HostAdapter for LinuxHost { info!(command = %info.command_name, "Sent SIGKILL via command name"); } } - - // Also force kill via process handle - let procs = self.processes.lock().unwrap(); - if let Some(p) = procs.get(&pid) { - let _ = p.kill(); + + // Also force kill via process handle (skip for Steam sessions) + let is_steam = session_info.as_ref().and_then(|info| info.steam_app_id).is_some(); + if !is_steam { + let procs = self.processes.lock().unwrap(); + if let Some(p) = procs.get(&pid) { + let _ = p.kill(); + } } } } diff --git a/crates/shepherd-host-linux/src/process.rs b/crates/shepherd-host-linux/src/process.rs index e4f54bd..b9cdd19 100644 --- a/crates/shepherd-host-linux/src/process.rs +++ b/crates/shepherd-host-linux/src/process.rs @@ -155,6 +155,59 @@ pub fn kill_flatpak_cgroup(app_id: &str, _signal: Signal) -> bool { stopped_any } +/// Find Steam game process IDs by Steam App ID (from environment variables) +pub fn find_steam_game_pids(app_id: u32) -> Vec { + let mut pids = Vec::new(); + let target = app_id.to_string(); + let keys = ["SteamAppId", "SteamAppID", "STEAM_APP_ID"]; + + if let Ok(entries) = std::fs::read_dir("/proc") { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if let Ok(pid) = name_str.parse::() { + let env_path = format!("/proc/{}/environ", pid); + let Ok(env_bytes) = std::fs::read(&env_path) else { + continue; + }; + + for var in env_bytes.split(|b| *b == 0) { + if var.is_empty() { + continue; + } + let Ok(var_str) = std::str::from_utf8(var) else { + continue; + }; + for key in &keys { + let prefix = format!("{}=", key); + if let Some(val) = var_str.strip_prefix(&prefix) { + if val == target { + pids.push(pid); + } + } + } + } + } + } + } + + pids +} + +/// Kill Steam game processes by Steam App ID +pub fn kill_steam_game_processes(app_id: u32, signal: Signal) -> bool { + let pids = find_steam_game_pids(app_id); + if pids.is_empty() { + return false; + } + + for pid in pids { + let _ = signal::kill(Pid::from_raw(pid), signal); + } + + true +} + /// Kill processes by command name using pkill pub fn kill_by_command(command_name: &str, signal: Signal) -> bool { let signal_name = match signal { diff --git a/crates/shepherd-launcher-ui/src/tile.rs b/crates/shepherd-launcher-ui/src/tile.rs index 6ab3fc0..c2b6ad7 100644 --- a/crates/shepherd-launcher-ui/src/tile.rs +++ b/crates/shepherd-launcher-ui/src/tile.rs @@ -79,6 +79,7 @@ impl LauncherTile { let fallback_icon = match entry.kind_tag { shepherd_api::EntryKindTag::Process => "application-x-executable", shepherd_api::EntryKindTag::Snap => "application-x-executable", + shepherd_api::EntryKindTag::Steam => "application-x-executable", shepherd_api::EntryKindTag::Flatpak => "application-x-executable", shepherd_api::EntryKindTag::Vm => "computer", shepherd_api::EntryKindTag::Media => "video-x-generic",