diff --git a/README.md b/README.md index 9bc59af..f780db4 100644 --- a/README.md +++ b/README.md @@ -104,16 +104,16 @@ All behavior shown above is driven entirely by declarative configuration. For the Minecraft example shown above: ```toml -# Minecraft via mc-installer snap -# Ubuntu: sudo snap install mc-installer +# Prism Launcher - Minecraft launcher (Flatpak) +# Install: flatpak install flathub org.prismlauncher.PrismLauncher [[entries]] -id = "minecraft" -label = "Minecraft" -icon = "minecraft" +id = "prism-launcher" +label = "Prism Launcher" +icon = "org.prismlauncher.PrismLauncher" [entries.kind] -type = "snap" -snap_name = "mc-installer" +type = "flatpak" +app_id = "org.prismlauncher.PrismLauncher" [entries.availability] [[entries.availability.windows]] @@ -160,7 +160,7 @@ compatibility infrastructure: * Wayland and Sway * Rust -* Snap +* Flatpak and Snap * Proton and WINE This project was written with the assistance of generative AI-based coding diff --git a/config.example.toml b/config.example.toml index eded285..b86eee8 100644 --- a/config.example.toml +++ b/config.example.toml @@ -165,52 +165,6 @@ max_run_seconds = 0 # Unlimited daily_quota_seconds = 0 # Unlimited cooldown_seconds = 0 # No cooldown -# Minecraft via mc-installer snap -# Ubuntu: sudo snap install mc-installer -[[entries]] -id = "minecraft" -label = "Minecraft" -icon = "~/.minecraft/launcher/icons/minecraft256.png" - -[entries.kind] -type = "snap" -snap_name = "mc-installer" - -[entries.availability] -[[entries.availability.windows]] -days = "weekdays" -start = "15:00" -end = "18:00" - -[[entries.availability.windows]] -days = "weekends" -start = "10:00" -end = "20:00" - -[entries.limits] -max_run_seconds = 1800 # 30 minutes (roughly 3 in-game days) -daily_quota_seconds = 3600 # 1 hour per day -cooldown_seconds = 600 # 10 minute cooldown - -[[entries.warnings]] -seconds_before = 600 -severity = "info" -message = "10 minutes left - start wrapping up!" - -[[entries.warnings]] -seconds_before = 120 -severity = "warn" -message = "2 minutes remaining - save your game!" - -[[entries.warnings]] -seconds_before = 30 -severity = "critical" -message = "30 seconds! Save NOW!" - -# Entry-specific volume restrictions (overrides global) -[entries.volume] -max_volume = 60 # Limit volume during gaming sessions - ## === Steam games === # Steam can be used via Canonical's Steam snap package: # https://snapcraft.io/steam @@ -267,6 +221,76 @@ days = "weekends" start = "10:00" end = "20:00" +## === Flatpak-based applications === +# Flatpak entries use the "flatpak" type for proper process management. +# Similar to Snap, Flatpak apps run in sandboxed environments and use +# systemd scopes for process management. + +# Prism Launcher - Minecraft launcher (Flatpak) +# Install: flatpak install flathub org.prismlauncher.PrismLauncher +[[entries]] +id = "prism-launcher" +label = "Prism Launcher" +icon = "org.prismlauncher.PrismLauncher" + +[entries.kind] +type = "flatpak" +app_id = "org.prismlauncher.PrismLauncher" + +[entries.availability] +[[entries.availability.windows]] +days = "weekdays" +start = "15:00" +end = "18:00" + +[[entries.availability.windows]] +days = "weekends" +start = "10:00" +end = "20:00" + +[entries.limits] +max_run_seconds = 1800 # 30 minutes (roughly 3 in-game days) +daily_quota_seconds = 3600 # 1 hour per day +cooldown_seconds = 600 # 10 minute cooldown + +[[entries.warnings]] +seconds_before = 600 +severity = "info" +message = "10 minutes left - start wrapping up!" + +[[entries.warnings]] +seconds_before = 120 +severity = "warn" +message = "2 minutes remaining - save your game!" + +[[entries.warnings]] +seconds_before = 30 +severity = "critical" +message = "30 seconds! Save NOW!" + +# Entry-specific volume restrictions (overrides global) +[entries.volume] +max_volume = 60 # Limit volume during gaming sessions + +# Krita - digital painting (Flatpak) +# Install: flatpak install flathub org.kde.krita +[[entries]] +id = "krita" +label = "Krita" +icon = "org.kde.krita" + +[entries.kind] +type = "flatpak" +app_id = "org.kde.krita" + +[entries.availability] +always = true + +[entries.limits] +max_run_seconds = 0 # Unlimited +daily_quota_seconds = 0 # Unlimited +cooldown_seconds = 0 # No cooldown + ## === Media === # Just use `mpv` to play media (for now). # Files can be local on your system or URLs (YouTube, etc). diff --git a/crates/shepherd-api/src/types.rs b/crates/shepherd-api/src/types.rs index 668d649..20bb6df 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, + Flatpak, Vm, Media, Custom, @@ -46,6 +47,17 @@ pub enum EntryKind { #[serde(default)] env: HashMap, }, + /// Flatpak application - uses systemd scope-based process management + Flatpak { + /// The Flatpak application ID (e.g., "org.prismlauncher.PrismLauncher") + app_id: String, + /// Additional command-line arguments + #[serde(default)] + args: Vec, + /// Additional environment variables + #[serde(default)] + env: HashMap, + }, Vm { driver: String, #[serde(default)] @@ -67,6 +79,7 @@ impl EntryKind { match self { EntryKind::Process { .. } => EntryKindTag::Process, EntryKind::Snap { .. } => EntryKindTag::Snap, + EntryKind::Flatpak { .. } => EntryKindTag::Flatpak, EntryKind::Vm { .. } => EntryKindTag::Vm, EntryKind::Media { .. } => EntryKindTag::Media, EntryKind::Custom { .. } => EntryKindTag::Custom, diff --git a/crates/shepherd-config/src/bin/validate-config.rs b/crates/shepherd-config/src/bin/validate-config.rs index 10c27da..7d12688 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::Flatpak { app_id, .. } => { + format!("flatpak ({})", app_id) + } EntryKind::Vm { driver, .. } => { format!("vm ({})", driver) } diff --git a/crates/shepherd-config/src/policy.rs b/crates/shepherd-config/src/policy.rs index 2389e52..ce6d113 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::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 }, RawEntryKind::Custom { type_name, payload } => EntryKind::Custom { diff --git a/crates/shepherd-config/src/schema.rs b/crates/shepherd-config/src/schema.rs index d1b5370..a68b4d1 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, }, + /// Flatpak application - uses systemd scope-based process management + Flatpak { + /// The Flatpak application ID (e.g., "org.prismlauncher.PrismLauncher") + app_id: String, + /// Additional command-line arguments + #[serde(default)] + args: Vec, + /// Additional environment variables + #[serde(default)] + env: HashMap, + }, Vm { driver: String, #[serde(default)] diff --git a/crates/shepherd-config/src/validation.rs b/crates/shepherd-config/src/validation.rs index 5905f0c..27dc3de 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::Flatpak { app_id, .. } => { + if app_id.is_empty() { + errors.push(ValidationError::EntryError { + entry_id: entry.id.clone(), + message: "app_id cannot be empty".into(), + }); + } + } RawEntryKind::Vm { driver, .. } => { if driver.is_empty() { errors.push(ValidationError::EntryError { diff --git a/crates/shepherd-host-api/src/capabilities.rs b/crates/shepherd-host-api/src/capabilities.rs index 4d5ca22..8bfed89 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::Flatpak); spawn_kinds.insert(EntryKindTag::Vm); spawn_kinds.insert(EntryKindTag::Media); diff --git a/crates/shepherd-host-linux/src/adapter.rs b/crates/shepherd-host-linux/src/adapter.rs index a8bd9dd..43b8e06 100644 --- a/crates/shepherd-host-linux/src/adapter.rs +++ b/crates/shepherd-host-linux/src/adapter.rs @@ -13,7 +13,7 @@ use std::time::Duration; use tokio::sync::mpsc; use tracing::{info, warn}; -use crate::process::{init, kill_by_command, kill_snap_cgroup, ManagedProcess}; +use crate::process::{init, kill_by_command, kill_flatpak_cgroup, kill_snap_cgroup, ManagedProcess}; /// Expand `~` at the beginning of a path to the user's home directory fn expand_tilde(path: &str) -> String { @@ -39,6 +39,7 @@ fn expand_args(args: &[String]) -> Vec { struct SessionInfo { command_name: String, snap_name: Option, + flatpak_app_id: Option, } /// Linux host adapter @@ -132,15 +133,15 @@ impl HostAdapter for LinuxHost { entry_kind: &EntryKind, options: SpawnOptions, ) -> HostResult { - // Extract argv, env, cwd, and snap_name based on entry kind - let (argv, env, cwd, snap_name) = match entry_kind { + // 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 { 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) + (argv, env.clone(), expanded_cwd, None, None) } EntryKind::Snap { snap_name, command, args, env } => { // For snap apps, we need to use 'snap run ' to launch them. @@ -153,7 +154,13 @@ impl HostAdapter for LinuxHost { argv.push(cmd.clone()); } argv.extend(expand_args(args)); - (argv, env.clone(), None, Some(snap_name.clone())) + (argv, env.clone(), None, Some(snap_name.clone()), None) + } + 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())) } EntryKind::Vm { driver, args } => { // Construct command line from VM driver @@ -166,13 +173,13 @@ impl HostAdapter for LinuxHost { argv.push(value.to_string()); } } - (argv, HashMap::new(), None, None) + (argv, HashMap::new(), 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) + (argv, HashMap::new(), None, None, None) } EntryKind::Custom { type_name: _, payload: _ } => { return Err(HostError::UnsupportedKind); @@ -180,19 +187,24 @@ impl HostAdapter for LinuxHost { }; // Get the command name for fallback killing - // For snap apps, use the snap_name (not "snap") 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 { snap.clone() + } else if let Some(ref app_id) = flatpak_app_id { + app_id.clone() } else { argv.first().cloned().unwrap_or_default() }; + + // Determine if this is a sandboxed app (snap or flatpak) + let sandboxed_app_name = snap_name.clone().or_else(|| flatpak_app_id.clone()); let proc = ManagedProcess::spawn( &argv, &env, cwd.as_ref(), options.log_path.clone(), - snap_name.clone(), + sandboxed_app_name, )?; let pid = proc.pid; @@ -202,9 +214,10 @@ impl HostAdapter for LinuxHost { let session_info_entry = SessionInfo { command_name: command_name.clone(), snap_name: snap_name.clone(), + flatpak_app_id: flatpak_app_id.clone(), }; self.session_info.lock().unwrap().insert(session_id.clone(), session_info_entry); - info!(session_id = %session_id, command = %command_name, snap = ?snap_name, "Tracking session info"); + info!(session_id = %session_id, command = %command_name, snap = ?snap_name, flatpak = ?flatpak_app_id, "Tracking session info"); let handle = HostSessionHandle::new( session_id, @@ -238,13 +251,16 @@ impl HostAdapter for LinuxHost { match mode { StopMode::Graceful { timeout } => { - // If this is a snap app, use cgroup-based killing (most reliable) + // If this is a snap or flatpak app, use cgroup-based killing (most reliable) if let Some(ref info) = session_info { 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(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"); } else { - // Fall back to command name for non-snap apps + // Fall back to command name for non-sandboxed apps kill_by_command(&info.command_name, nix::sys::signal::Signal::SIGTERM); info!(command = %info.command_name, "Sent SIGTERM via command name"); } @@ -262,11 +278,14 @@ impl HostAdapter for LinuxHost { let start = std::time::Instant::now(); loop { if start.elapsed() >= timeout { - // Force kill after timeout using snap cgroup or command name + // Force kill after timeout using snap/flatpak cgroup or command name if let Some(ref info) = session_info { 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(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)"); } else { kill_by_command(&info.command_name, nix::sys::signal::Signal::SIGKILL); info!(command = %info.command_name, "Sent SIGKILL via command name (timeout)"); @@ -292,11 +311,14 @@ impl HostAdapter for LinuxHost { } } StopMode::Force => { - // Force kill via snap cgroup or command name + // Force kill via snap/flatpak cgroup or command name if let Some(ref info) = session_info { 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(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"); } else { kill_by_command(&info.command_name, nix::sys::signal::Signal::SIGKILL); info!(command = %info.command_name, "Sent SIGKILL via command name"); diff --git a/crates/shepherd-host-linux/src/process.rs b/crates/shepherd-host-linux/src/process.rs index 1abfbdd..e4f54bd 100644 --- a/crates/shepherd-host-linux/src/process.rs +++ b/crates/shepherd-host-linux/src/process.rs @@ -91,6 +91,70 @@ pub fn kill_snap_cgroup(snap_name: &str, _signal: Signal) -> bool { stopped_any } +/// Kill all processes in a Flatpak app's cgroup using systemd +/// Flatpak apps create scopes at: app-flatpak--.scope +/// For example: app-flatpak-org.prismlauncher.PrismLauncher-12345.scope +/// Similar to snap apps, we use systemctl --user to manage the scopes. +pub fn kill_flatpak_cgroup(app_id: &str, _signal: Signal) -> bool { + let uid = nix::unistd::getuid().as_raw(); + let base_path = format!( + "/sys/fs/cgroup/user.slice/user-{}.slice/user@{}.service/app.slice", + uid, uid + ); + + // Flatpak uses a different naming pattern than snap + // The app_id dots are preserved: app-flatpak-org.example.App-.scope + let pattern = format!("app-flatpak-{}-", app_id); + + let base = std::path::Path::new(&base_path); + if !base.exists() { + debug!(path = %base_path, "Flatpak cgroup base path doesn't exist"); + return false; + } + + let mut stopped_any = false; + + if let Ok(entries) = std::fs::read_dir(base) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + if name_str.starts_with(&pattern) && name_str.ends_with(".scope") { + let scope_name = name_str.to_string(); + + // Always use SIGKILL for flatpak apps to prevent self-restart behavior + // Using systemctl kill --signal=KILL sends SIGKILL to all processes in scope + let result = Command::new("systemctl") + .args(["--user", "kill", "--signal=KILL", &scope_name]) + .output(); + + match result { + Ok(output) => { + if output.status.success() { + info!(scope = %scope_name, "Killed flatpak scope via systemctl SIGKILL"); + stopped_any = true; + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + warn!(scope = %scope_name, stderr = %stderr, "systemctl kill command failed"); + } + } + Err(e) => { + warn!(scope = %scope_name, error = %e, "Failed to run systemctl"); + } + } + } + } + } + + if stopped_any { + info!(app_id = app_id, "Killed flatpak scope(s) via systemctl SIGKILL"); + } else { + debug!(app_id = app_id, "No flatpak scope found to kill"); + } + + stopped_any +} + /// 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 51ba455..6ab3fc0 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::Flatpak => "application-x-executable", shepherd_api::EntryKindTag::Vm => "computer", shepherd_api::EntryKindTag::Media => "video-x-generic", shepherd_api::EntryKindTag::Custom => "applications-other", diff --git a/scripts/lib/install.sh b/scripts/lib/install.sh index bdc8bd3..33b659f 100755 --- a/scripts/lib/install.sh +++ b/scripts/lib/install.sh @@ -127,9 +127,10 @@ EOF install_config() { local user="${1:-}" local source_config="${2:-}" + local force="${3:-false}" if [[ -z "$user" ]]; then - die "Usage: shepherd install config --user USER [--source CONFIG]" + die "Usage: shepherd install config --user USER [--source CONFIG] [--force]" fi validate_user "$user" @@ -161,7 +162,15 @@ install_config() { # Check if config already exists if maybe_sudo test -f "$dst_config"; then - warn "Config file already exists at $dst_config, skipping" + if [[ "$force" == "true" ]]; then + warn "Overwriting existing config at $dst_config" + maybe_sudo cp "$source_config" "$dst_config" + maybe_sudo chown "$user:$user" "$dst_config" + maybe_sudo chmod 0644 "$dst_config" + success "Overwrote user configuration for $user" + else + warn "Config file already exists at $dst_config, skipping (use --force to overwrite)" + fi else # Copy config file maybe_sudo cp "$source_config" "$dst_config" @@ -175,9 +184,10 @@ install_config() { install_all() { local user="${1:-}" local prefix="${2:-$DEFAULT_PREFIX}" + local force="${3:-false}" if [[ -z "$user" ]]; then - die "Usage: shepherd install all --user USER [--prefix PREFIX]" + die "Usage: shepherd install all --user USER [--prefix PREFIX] [--force]" fi require_root @@ -188,7 +198,7 @@ install_all() { install_bins "$prefix" install_sway_config "$prefix" install_desktop_entry "$prefix" - install_config "$user" + install_config "$user" "" "$force" success "Installation complete!" info "" @@ -206,6 +216,7 @@ install_main() { local user="" local prefix="$DEFAULT_PREFIX" local source_config="" + local force="false" # Parse remaining arguments while [[ $# -gt 0 ]]; do @@ -222,6 +233,10 @@ install_main() { source_config="$2" shift 2 ;; + --force|-f) + force="true" + shift + ;; *) die "Unknown option: $1" ;; @@ -233,7 +248,7 @@ install_main() { install_bins "$prefix" ;; config) - install_config "$user" "$source_config" + install_config "$user" "$source_config" "$force" ;; sway-config) install_sway_config "$prefix" @@ -242,7 +257,7 @@ install_main() { install_desktop_entry "$prefix" ;; all) - install_all "$user" "$prefix" + install_all "$user" "$prefix" "$force" ;; ""|help|-h|--help) cat <