Merge pull request #22 from aarmea/u/aarmea/18/flatpak-type

Implement first-class Flatpak support
This commit is contained in:
Albert Armea 2026-01-09 22:32:04 -05:00 committed by GitHub
commit b7f2294a81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 239 additions and 74 deletions

View file

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

View file

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

View file

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

View file

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

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::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 {

View file

@ -116,6 +116,17 @@ pub enum RawEntryKind {
#[serde(default)]
env: HashMap<String, String>,
},
/// 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<String>,
/// Additional environment variables
#[serde(default)]
env: HashMap<String, String>,
},
Vm {
driver: String,
#[serde(default)]

View file

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

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::Flatpak);
spawn_kinds.insert(EntryKindTag::Vm);
spawn_kinds.insert(EntryKindTag::Media);

View file

@ -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<String> {
struct SessionInfo {
command_name: String,
snap_name: Option<String>,
flatpak_app_id: Option<String>,
}
/// Linux host adapter
@ -132,15 +133,15 @@ impl HostAdapter for LinuxHost {
entry_kind: &EntryKind,
options: SpawnOptions,
) -> HostResult<HostSessionHandle> {
// 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 <snap_name>' 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 <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()))
}
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");

View file

@ -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-<app_id>-<number>.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-<number>.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 {

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::Flatpak => "application-x-executable",
shepherd_api::EntryKindTag::Vm => "computer",
shepherd_api::EntryKindTag::Media => "video-x-generic",
shepherd_api::EntryKindTag::Custom => "applications-other",

View file

@ -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 <<EOF
@ -259,6 +274,7 @@ Options:
--user USER Target user for config deployment (required for config/all)
--prefix PREFIX Installation prefix (default: $DEFAULT_PREFIX)
--source CONFIG Source config file (default: config.example.toml)
--force, -f Overwrite existing configuration files
Environment:
DESTDIR Installation root for packaging (default: empty)
@ -266,6 +282,7 @@ Environment:
Examples:
shepherd install bins --prefix /usr/local
shepherd install config --user kiosk
shepherd install config --user kiosk --force
shepherd install all --user kiosk --prefix /usr
EOF
;;