Merge pull request #25 from aarmea/u/aarmea/4/steam-type

Add "steam"-specific type
This commit is contained in:
Albert Armea 2026-02-07 16:55:09 -05:00 committed by GitHub
commit 6e64e8e69d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 267 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,49 @@ 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 Ok(mut map) = steam_sessions.lock() {
map.entry(session.pid)
.and_modify(|entry| 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 +204,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 +225,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 +255,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 +272,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 +299,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 +311,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 +353,12 @@ 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 Ok(mut map) = self.steam_sessions.lock() {
map.entry(pid).and_modify(|entry| 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");
@ -266,8 +369,9 @@ impl HostAdapter for LinuxHost {
} }
} }
// 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 +387,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)");
@ -292,16 +399,25 @@ impl HostAdapter for LinuxHost {
} }
} }
// 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 +432,12 @@ 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 Ok(mut map) = self.steam_sessions.lock() {
map.entry(pid).and_modify(|entry| 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");
@ -325,10 +447,13 @@ impl HostAdapter for LinuxHost {
} }
} }
// 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,60 @@ 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 var_str
.strip_prefix(&prefix)
.is_some_and(|val| 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",