Add "steam"-specific type

This implementation allows each platform to choose how to launch Steam (on Linux, we use the snap as the examples suggested before), and keeps Steam alive after an activity exits so that save sync, game updates, etc. can continue to run.

Change written by Codex 5.2 on medium:

Consider this GitHub issue https://github.com/aarmea/shepherd-launcher/issues/4. On Linux, an activity that uses the "steam" type should launch Steam via the snap as shown in the example configuration in this repository.

Go ahead and implement the feature. I'm expecting one of the tricky bits to be killing the activity while keeping Steam alive, as we can no longer just kill the Steam snap cgroup.
This commit is contained in:
Albert Armea 2026-02-07 16:22:55 -05:00
parent 4446f518a7
commit 9da95a27b3
13 changed files with 271 additions and 33 deletions

View file

@ -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]]

View file

@ -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<String, String>,
},
/// 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<String>,
/// Additional environment variables
#[serde(default)]
env: HashMap<String, String>,
},
/// 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,

View file

@ -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" } }

View file

@ -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)
}

View file

@ -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 },

View file

@ -116,6 +116,17 @@ pub enum RawEntryKind {
#[serde(default)]
env: HashMap<String, String>,
},
/// 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<String>,
/// Additional environment variables
#[serde(default)]
env: HashMap<String, String>,
},
/// Flatpak application - uses systemd scope-based process management
Flatpak {
/// The Flatpak application ID (e.g., "org.prismlauncher.PrismLauncher")

View file

@ -71,6 +71,14 @@ fn validate_entry(entry: &RawEntry, config: &RawConfig) -> Vec<ValidationError>
});
}
}
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 {

View file

@ -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 */ }

View file

@ -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);

View file

@ -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

View file

@ -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<String>,
flatpak_app_id: Option<String>,
steam_app_id: Option<u32>,
}
#[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<Mutex<HashMap<u32, ManagedProcess>>>,
/// Track session info for killing
session_info: Arc<Mutex<HashMap<SessionId, SessionInfo>>>,
steam_sessions: Arc<Mutex<HashMap<u32, SteamSession>>>,
event_tx: mpsc::UnboundedSender<HostEvent>,
event_rx: Arc<Mutex<Option<mpsc::UnboundedReceiver<HostEvent>>>>,
}
@ -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<u32> = {
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<SteamSession> = {
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<HostSessionHandle> {
// 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 <snap_name>' 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/<app_id>
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 <app_id>' 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();
}
}
}
}

View file

@ -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<i32> {
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::<i32>() {
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 {

View file

@ -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",