Implement Flatpak application type
This commit is contained in:
parent
b98fbc598f
commit
d823ed0a19
11 changed files with 216 additions and 68 deletions
16
README.md
16
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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue