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" icon = "~/Games/Icons/Celeste.png"
[entries.kind] [entries.kind]
type = "snap" type = "steam"
snap_name = "steam" app_id = 504230 # Steam App ID
args = ["steam://rungameid/504230"] # Steam App ID (passed to 'snap run steam')
[entries.availability] [entries.availability]
[[entries.availability.windows]] [[entries.availability.windows]]
@ -206,9 +205,8 @@ label = "A Short Hike"
icon = "~/Games/Icons/A_Short_Hike.png" icon = "~/Games/Icons/A_Short_Hike.png"
[entries.kind] [entries.kind]
type = "snap" type = "steam"
snap_name = "steam" app_id = 1055540 # Steam App ID
args = ["steam://rungameid/1055540"] # Steam App ID (passed to 'snap run steam')
[entries.availability] [entries.availability]
[[entries.availability.windows]] [[entries.availability.windows]]

View file

@ -13,6 +13,7 @@ use std::time::Duration;
pub enum EntryKindTag { pub enum EntryKindTag {
Process, Process,
Snap, Snap,
Steam,
Flatpak, Flatpak,
Vm, Vm,
Media, Media,
@ -47,6 +48,17 @@ pub enum EntryKind {
#[serde(default)] #[serde(default)]
env: HashMap<String, String>, 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 application - uses systemd scope-based process management
Flatpak { Flatpak {
/// The Flatpak application ID (e.g., "org.prismlauncher.PrismLauncher") /// The Flatpak application ID (e.g., "org.prismlauncher.PrismLauncher")
@ -79,6 +91,7 @@ impl EntryKind {
match self { match self {
EntryKind::Process { .. } => EntryKindTag::Process, EntryKind::Process { .. } => EntryKindTag::Process,
EntryKind::Snap { .. } => EntryKindTag::Snap, EntryKind::Snap { .. } => EntryKindTag::Snap,
EntryKind::Steam { .. } => EntryKindTag::Steam,
EntryKind::Flatpak { .. } => EntryKindTag::Flatpak, EntryKind::Flatpak { .. } => EntryKindTag::Flatpak,
EntryKind::Vm { .. } => EntryKindTag::Vm, EntryKind::Vm { .. } => EntryKindTag::Vm,
EntryKind::Media { .. } => EntryKindTag::Media, EntryKind::Media { .. } => EntryKindTag::Media,

View file

@ -110,6 +110,9 @@ kind = { type = "process", command = "/usr/bin/game", args = ["--fullscreen"] }
# Snap application # Snap application
kind = { type = "snap", snap_name = "mc-installer" } kind = { type = "snap", snap_name = "mc-installer" }
# Steam game (via Steam snap)
kind = { type = "steam", app_id = 504230 }
# Virtual machine (future) # Virtual machine (future)
kind = { type = "vm", driver = "qemu", args = { disk = "game.qcow2" } } kind = { type = "vm", driver = "qemu", args = { disk = "game.qcow2" } }

View file

@ -54,6 +54,9 @@ fn main() -> ExitCode {
EntryKind::Snap { snap_name, .. } => { EntryKind::Snap { snap_name, .. } => {
format!("snap ({})", snap_name) format!("snap ({})", snap_name)
} }
EntryKind::Steam { app_id, .. } => {
format!("steam ({})", app_id)
}
EntryKind::Flatpak { app_id, .. } => { EntryKind::Flatpak { app_id, .. } => {
format!("flatpak ({})", app_id) format!("flatpak ({})", app_id)
} }

View file

@ -256,6 +256,7 @@ fn convert_entry_kind(raw: RawEntryKind) -> EntryKind {
match raw { match raw {
RawEntryKind::Process { command, args, env, cwd } => EntryKind::Process { command, args, env, cwd }, 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::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::Flatpak { app_id, args, env } => EntryKind::Flatpak { app_id, args, env },
RawEntryKind::Vm { driver, args } => EntryKind::Vm { driver, args }, RawEntryKind::Vm { driver, args } => EntryKind::Vm { driver, args },
RawEntryKind::Media { library_id, args } => EntryKind::Media { library_id, args }, RawEntryKind::Media { library_id, args } => EntryKind::Media { library_id, args },

View file

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

View file

@ -30,6 +30,7 @@ let caps = host.capabilities();
// Check supported entry kinds // Check supported entry kinds
if caps.supports_kind(EntryKindTag::Process) { /* ... */ } if caps.supports_kind(EntryKindTag::Process) { /* ... */ }
if caps.supports_kind(EntryKindTag::Snap) { /* ... */ } if caps.supports_kind(EntryKindTag::Snap) { /* ... */ }
if caps.supports_kind(EntryKindTag::Steam) { /* ... */ }
// Check enforcement capabilities // Check enforcement capabilities
if caps.can_kill_forcefully { /* Can use SIGKILL/TerminateProcess */ } if caps.can_kill_forcefully { /* Can use SIGKILL/TerminateProcess */ }

View file

@ -59,6 +59,7 @@ impl HostCapabilities {
let mut spawn_kinds = HashSet::new(); let mut spawn_kinds = HashSet::new();
spawn_kinds.insert(EntryKindTag::Process); spawn_kinds.insert(EntryKindTag::Process);
spawn_kinds.insert(EntryKindTag::Snap); spawn_kinds.insert(EntryKindTag::Snap);
spawn_kinds.insert(EntryKindTag::Steam);
spawn_kinds.insert(EntryKindTag::Flatpak); spawn_kinds.insert(EntryKindTag::Flatpak);
spawn_kinds.insert(EntryKindTag::Vm); spawn_kinds.insert(EntryKindTag::Vm);
spawn_kinds.insert(EntryKindTag::Media); 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?; 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 ### Stopping Sessions
```rust ```rust

View file

@ -3,17 +3,20 @@
use async_trait::async_trait; use async_trait::async_trait;
use shepherd_api::EntryKind; use shepherd_api::EntryKind;
use shepherd_host_api::{ use shepherd_host_api::{
HostAdapter, HostCapabilities, HostError, HostEvent, HostHandlePayload, ExitStatus, HostAdapter, HostCapabilities, HostError, HostEvent, HostHandlePayload,
HostResult, HostSessionHandle, SpawnOptions, StopMode, HostResult, HostSessionHandle, SpawnOptions, StopMode,
}; };
use shepherd_util::SessionId; use shepherd_util::SessionId;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{info, warn}; 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 /// Expand `~` at the beginning of a path to the user's home directory
fn expand_tilde(path: &str) -> String { fn expand_tilde(path: &str) -> String {
@ -40,6 +43,15 @@ struct SessionInfo {
command_name: String, command_name: String,
snap_name: Option<String>, snap_name: Option<String>,
flatpak_app_id: 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 /// Linux host adapter
@ -48,6 +60,7 @@ pub struct LinuxHost {
processes: Arc<Mutex<HashMap<u32, ManagedProcess>>>, processes: Arc<Mutex<HashMap<u32, ManagedProcess>>>,
/// Track session info for killing /// Track session info for killing
session_info: Arc<Mutex<HashMap<SessionId, SessionInfo>>>, session_info: Arc<Mutex<HashMap<SessionId, SessionInfo>>>,
steam_sessions: Arc<Mutex<HashMap<u32, SteamSession>>>,
event_tx: mpsc::UnboundedSender<HostEvent>, event_tx: mpsc::UnboundedSender<HostEvent>,
event_rx: Arc<Mutex<Option<mpsc::UnboundedReceiver<HostEvent>>>>, event_rx: Arc<Mutex<Option<mpsc::UnboundedReceiver<HostEvent>>>>,
} }
@ -63,6 +76,7 @@ impl LinuxHost {
capabilities: HostCapabilities::linux_full(), capabilities: HostCapabilities::linux_full(),
processes: Arc::new(Mutex::new(HashMap::new())), processes: Arc::new(Mutex::new(HashMap::new())),
session_info: 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_tx: tx,
event_rx: Arc::new(Mutex::new(Some(rx))), event_rx: Arc::new(Mutex::new(Some(rx))),
} }
@ -71,6 +85,7 @@ impl LinuxHost {
/// Start the background process monitor /// Start the background process monitor
pub fn start_monitor(&self) -> tokio::task::JoinHandle<()> { pub fn start_monitor(&self) -> tokio::task::JoinHandle<()> {
let processes = self.processes.clone(); let processes = self.processes.clone();
let steam_sessions = self.steam_sessions.clone();
let event_tx = self.event_tx.clone(); let event_tx = self.event_tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
@ -78,13 +93,22 @@ impl LinuxHost {
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;
let mut exited = Vec::new(); let mut exited = Vec::new();
let steam_pids: HashSet<u32> = {
steam_sessions
.lock()
.unwrap()
.keys()
.cloned()
.collect()
};
{ {
let mut procs = processes.lock().unwrap(); let mut procs = processes.lock().unwrap();
for (pid, proc) in procs.iter_mut() { for (pid, proc) in procs.iter_mut() {
match proc.try_wait() { match proc.try_wait() {
Ok(Some(status)) => { 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) => {} Ok(None) => {}
Err(e) => { Err(e) => {
@ -93,12 +117,16 @@ impl LinuxHost {
} }
} }
for (pid, _, _) in &exited { for (pid, _, _, _) in &exited {
procs.remove(pid); 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"); 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 // 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 }); 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, entry_kind: &EntryKind,
options: SpawnOptions, options: SpawnOptions,
) -> HostResult<HostSessionHandle> { ) -> HostResult<HostSessionHandle> {
// Extract argv, env, cwd, snap_name, and flatpak_app_id based on 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) = match entry_kind { let (argv, env, cwd, snap_name, flatpak_app_id, steam_app_id) = match entry_kind {
EntryKind::Process { command, args, env, cwd } => { EntryKind::Process { command, args, env, cwd } => {
let mut argv = vec![expand_tilde(command)]; let mut argv = vec![expand_tilde(command)];
argv.extend(expand_args(args)); argv.extend(expand_args(args));
let expanded_cwd = cwd.as_ref().map(|c| { let expanded_cwd = cwd.as_ref().map(|c| {
std::path::PathBuf::from(expand_tilde(&c.to_string_lossy())) 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 } => { EntryKind::Snap { snap_name, command, args, env } => {
// For snap apps, we need to use 'snap run <snap_name>' to launch them. // 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.push(cmd.clone());
} }
argv.extend(expand_args(args)); 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 } => { EntryKind::Flatpak { app_id, args, env } => {
// For Flatpak apps, we use 'flatpak run <app_id>' to launch them. // 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()]; let mut argv = vec!["flatpak".to_string(), "run".to_string(), app_id.clone()];
argv.extend(expand_args(args)); 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 } => { EntryKind::Vm { driver, args } => {
// Construct command line from VM driver // Construct command line from VM driver
@ -173,13 +256,13 @@ impl HostAdapter for LinuxHost {
argv.push(value.to_string()); argv.push(value.to_string());
} }
} }
(argv, HashMap::new(), None, None, None) (argv, HashMap::new(), None, None, None, None)
} }
EntryKind::Media { library_id, args: _ } => { EntryKind::Media { library_id, args: _ } => {
// For media, we'd typically launch a media player // For media, we'd typically launch a media player
// This is a placeholder - real implementation would integrate with a player // This is a placeholder - real implementation would integrate with a player
let argv = vec!["xdg-open".to_string(), expand_tilde(library_id)]; 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: _ } => { EntryKind::Custom { type_name: _, payload: _ } => {
return Err(HostError::UnsupportedKind); 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 // 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 { let command_name = if let Some(ref snap) = snap_name {
snap.clone() snap.clone()
} else if steam_app_id.is_some() {
"steam".to_string()
} else if let Some(ref app_id) = flatpak_app_id { } else if let Some(ref app_id) = flatpak_app_id {
app_id.clone() app_id.clone()
} else { } else {
@ -215,6 +300,7 @@ impl HostAdapter for LinuxHost {
command_name: command_name.clone(), command_name: command_name.clone(),
snap_name: snap_name.clone(), snap_name: snap_name.clone(),
flatpak_app_id: flatpak_app_id.clone(), flatpak_app_id: flatpak_app_id.clone(),
steam_app_id,
}; };
self.session_info.lock().unwrap().insert(session_id.clone(), session_info_entry); 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"); 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); 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"); info!(pid = pid, pgid = pgid, "Spawned process");
Ok(handle) Ok(handle)
@ -256,6 +354,14 @@ impl HostAdapter for LinuxHost {
if let Some(ref snap) = info.snap_name { if let Some(ref snap) = info.snap_name {
kill_snap_cgroup(snap, nix::sys::signal::Signal::SIGTERM); kill_snap_cgroup(snap, nix::sys::signal::Signal::SIGTERM);
info!(snap = %snap, "Sent SIGTERM via snap cgroup"); 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 { } else if let Some(ref app_id) = info.flatpak_app_id {
kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGTERM); kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGTERM);
info!(flatpak = %app_id, "Sent SIGTERM via flatpak cgroup"); 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"); 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(); let procs = self.processes.lock().unwrap();
if let Some(p) = procs.get(&pid) { if let Some(p) = procs.get(&pid) {
let _ = p.terminate(); let _ = p.terminate();
@ -283,6 +390,9 @@ impl HostAdapter for LinuxHost {
if let Some(ref snap) = info.snap_name { if let Some(ref snap) = info.snap_name {
kill_snap_cgroup(snap, nix::sys::signal::Signal::SIGKILL); kill_snap_cgroup(snap, nix::sys::signal::Signal::SIGKILL);
info!(snap = %snap, "Sent SIGKILL via snap cgroup (timeout)"); 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 { } else if let Some(ref app_id) = info.flatpak_app_id {
kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGKILL); kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGKILL);
info!(flatpak = %app_id, "Sent SIGKILL via flatpak cgroup (timeout)"); 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)"); info!(command = %info.command_name, "Sent SIGKILL via command name (timeout)");
} }
} }
// Also force kill via process handle // Also force kill via process handle (skip for Steam sessions)
let procs = self.processes.lock().unwrap(); if !is_steam {
if let Some(p) = procs.get(&pid) { let procs = self.processes.lock().unwrap();
let _ = p.kill(); if let Some(p) = procs.get(&pid) {
let _ = p.kill();
}
} }
break; break;
} }
// Check if process is still running // 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 { if !still_running {
break; break;
@ -316,6 +435,14 @@ impl HostAdapter for LinuxHost {
if let Some(ref snap) = info.snap_name { if let Some(ref snap) = info.snap_name {
kill_snap_cgroup(snap, nix::sys::signal::Signal::SIGKILL); kill_snap_cgroup(snap, nix::sys::signal::Signal::SIGKILL);
info!(snap = %snap, "Sent SIGKILL via snap cgroup"); 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 { } else if let Some(ref app_id) = info.flatpak_app_id {
kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGKILL); kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGKILL);
info!(flatpak = %app_id, "Sent SIGKILL via flatpak cgroup"); 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"); info!(command = %info.command_name, "Sent SIGKILL via command name");
} }
} }
// Also force kill via process handle // Also force kill via process handle (skip for Steam sessions)
let procs = self.processes.lock().unwrap(); let is_steam = session_info.as_ref().and_then(|info| info.steam_app_id).is_some();
if let Some(p) = procs.get(&pid) { if !is_steam {
let _ = p.kill(); 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 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 /// Kill processes by command name using pkill
pub fn kill_by_command(command_name: &str, signal: Signal) -> bool { pub fn kill_by_command(command_name: &str, signal: Signal) -> bool {
let signal_name = match signal { let signal_name = match signal {

View file

@ -79,6 +79,7 @@ impl LauncherTile {
let fallback_icon = match entry.kind_tag { let fallback_icon = match entry.kind_tag {
shepherd_api::EntryKindTag::Process => "application-x-executable", shepherd_api::EntryKindTag::Process => "application-x-executable",
shepherd_api::EntryKindTag::Snap => "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::Flatpak => "application-x-executable",
shepherd_api::EntryKindTag::Vm => "computer", shepherd_api::EntryKindTag::Vm => "computer",
shepherd_api::EntryKindTag::Media => "video-x-generic", shepherd_api::EntryKindTag::Media => "video-x-generic",