Merge pull request #17 from aarmea/u/aarmea/fix/mc-launcher-snap

Merging now, Minecraft specific issue is tracked in #18
This commit is contained in:
Albert Armea 2026-01-04 18:38:16 -05:00 committed by GitHub
commit 51b81c2a5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 178 additions and 25 deletions

7
Cargo.lock generated
View file

@ -1187,6 +1187,12 @@ dependencies = [
"lazy_static", "lazy_static",
] ]
[[package]]
name = "shell-escape"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f"
[[package]] [[package]]
name = "shepherd-api" name = "shepherd-api"
version = "0.1.0" version = "0.1.0"
@ -1252,6 +1258,7 @@ dependencies = [
"dirs", "dirs",
"nix", "nix",
"serde", "serde",
"shell-escape",
"shepherd-api", "shepherd-api",
"shepherd-host-api", "shepherd-host-api",
"shepherd-util", "shepherd-util",

View file

@ -1,7 +1,7 @@
# shepherd-launcher # shepherd-launcher
A child-friendly, parent-guided launcher for Wayland, allowing supervised A child-friendly, parent-guided desktop environment *alternative* for Wayland,
access to applications and content that you define. allowing supervised access to applications and content that you define.
Its primary goal is to return control of child-focused computing to parents, Its primary goal is to return control of child-focused computing to parents,
not software or hardware vendors, by providing: not software or hardware vendors, by providing:

View file

@ -12,6 +12,12 @@ config_version = 1
# log_dir = "~/.local/state/shepherdd" # log_dir = "~/.local/state/shepherdd"
# data_dir = "~/.local/share/shepherdd" # data_dir = "~/.local/share/shepherdd"
# Capture stdout/stderr from child applications to log files
# Logs are written to child_log_dir (or log_dir/sessions if not set)
# File format: <entry_id>_<timestamp>.log
# capture_child_output = true
# child_log_dir = "~/.local/state/shepherdd/sessions"
# Default max run duration if not specified per entry (1 hour) # Default max run duration if not specified per entry (1 hour)
# Set to 0 for unlimited (no time limit) # Set to 0 for unlimited (no time limit)
default_max_run_seconds = 3600 default_max_run_seconds = 3600
@ -308,13 +314,17 @@ max_run_seconds = 0 # Unlimited: sleep/study aid
daily_quota_seconds = 0 # Unlimited daily_quota_seconds = 0 # Unlimited
cooldown_seconds = 0 # No cooldown cooldown_seconds = 0 # No cooldown
# Example: Disabled entry # Terminal for debugging only
[[entries]] [[entries]]
id = "disabled-game" id = "terminal"
label = "Game Under Maintenance" label = "Terminal"
disabled = true icon = "utilities-terminal"
disabled_reason = "This game is being updated" disabled = true # Typically disabled, since it's:
disabled_reason = "For debugging only"
[entries.kind] [entries.kind]
type = "process" type = "process"
command = "/bin/false" command = "ptyxis"
[entries.availability]
always = true

View file

@ -77,17 +77,28 @@ pub struct ServiceConfig {
pub socket_path: PathBuf, pub socket_path: PathBuf,
pub log_dir: PathBuf, pub log_dir: PathBuf,
pub data_dir: PathBuf, pub data_dir: PathBuf,
/// Whether to capture stdout/stderr from child applications
pub capture_child_output: bool,
/// Directory for child application logs
pub child_log_dir: PathBuf,
} }
impl ServiceConfig { impl ServiceConfig {
fn from_raw(raw: RawServiceConfig) -> Self { fn from_raw(raw: RawServiceConfig) -> Self {
let log_dir = raw
.log_dir
.clone()
.unwrap_or_else(default_log_dir);
let child_log_dir = raw
.child_log_dir
.unwrap_or_else(|| log_dir.join("sessions"));
Self { Self {
socket_path: raw socket_path: raw
.socket_path .socket_path
.unwrap_or_else(socket_path_without_env), .unwrap_or_else(socket_path_without_env),
log_dir: raw log_dir,
.log_dir capture_child_output: raw.capture_child_output,
.unwrap_or_else(default_log_dir), child_log_dir,
data_dir: raw data_dir: raw
.data_dir .data_dir
.unwrap_or_else(default_data_dir), .unwrap_or_else(default_data_dir),
@ -97,10 +108,13 @@ impl ServiceConfig {
impl Default for ServiceConfig { impl Default for ServiceConfig {
fn default() -> Self { fn default() -> Self {
let log_dir = default_log_dir();
Self { Self {
socket_path: socket_path_without_env(), socket_path: socket_path_without_env(),
log_dir: default_log_dir(), child_log_dir: log_dir.join("sessions"),
log_dir,
data_dir: default_data_dir(), data_dir: default_data_dir(),
capture_child_output: false,
} }
} }
} }

View file

@ -31,6 +31,14 @@ pub struct RawServiceConfig {
/// Data directory for store (default: $XDG_DATA_HOME/shepherdd) /// Data directory for store (default: $XDG_DATA_HOME/shepherdd)
pub data_dir: Option<PathBuf>, pub data_dir: Option<PathBuf>,
/// Capture stdout/stderr from child applications to log files
/// Files are written to child_log_dir (or log_dir/sessions if not set)
#[serde(default)]
pub capture_child_output: bool,
/// Directory for child application logs (default: log_dir/sessions)
pub child_log_dir: Option<PathBuf>,
/// Default warning thresholds (can be overridden per entry) /// Default warning thresholds (can be overridden per entry)
pub default_warnings: Option<Vec<RawWarningThreshold>>, pub default_warnings: Option<Vec<RawWarningThreshold>>,

View file

@ -16,6 +16,7 @@ tokio = { workspace = true }
nix = { workspace = true } nix = { workspace = true }
async-trait = "0.1" async-trait = "0.1"
dirs = "5.0" dirs = "5.0"
shell-escape = "0.1"
[dev-dependencies] [dev-dependencies]
tempfile = { workspace = true } tempfile = { workspace = true }

View file

@ -191,7 +191,7 @@ impl HostAdapter for LinuxHost {
&argv, &argv,
&env, &env,
cwd.as_ref(), cwd.as_ref(),
options.capture_stdout || options.capture_stderr, options.log_path.clone(),
snap_name.clone(), snap_name.clone(),
)?; )?;

View file

@ -3,7 +3,9 @@
use nix::sys::signal::{self, Signal}; use nix::sys::signal::{self, Signal};
use nix::unistd::Pid; use nix::unistd::Pid;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::File;
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
@ -126,19 +128,57 @@ impl ManagedProcess {
/// ///
/// If `snap_name` is provided, the process is treated as a snap app and will use /// If `snap_name` is provided, the process is treated as a snap app and will use
/// systemd scope-based killing instead of signal-based killing. /// systemd scope-based killing instead of signal-based killing.
///
/// If `log_path` is provided, stdout and stderr will be redirected to that file.
/// For snap apps, we use `script` to capture output from all child processes
/// via a pseudo-terminal, since snap child processes don't inherit file descriptors.
pub fn spawn( pub fn spawn(
argv: &[String], argv: &[String],
env: &HashMap<String, String>, env: &HashMap<String, String>,
cwd: Option<&std::path::PathBuf>, cwd: Option<&std::path::PathBuf>,
capture_output: bool, log_path: Option<PathBuf>,
snap_name: Option<String>, snap_name: Option<String>,
) -> HostResult<Self> { ) -> HostResult<Self> {
if argv.is_empty() { if argv.is_empty() {
return Err(HostError::SpawnFailed("Empty argv".into())); return Err(HostError::SpawnFailed("Empty argv".into()));
} }
let program = &argv[0]; // For snap apps with log capture, wrap with `script` to capture all child output
let args = &argv[1..]; // via a pseudo-terminal. Snap child processes don't inherit file descriptors,
// but they do write to the controlling terminal.
let (actual_argv, actual_log_path) = match (&snap_name, &log_path) {
(Some(_), Some(log_file)) => {
// Create parent directory if it doesn't exist
if let Some(parent) = log_file.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
warn!(path = %parent.display(), error = %e, "Failed to create log directory");
}
// Build command: script -q -c "original command" logfile
// -q: quiet mode (no start/done messages)
// -c: command to run
let original_cmd = argv.iter()
.map(|arg| shell_escape::escape(std::borrow::Cow::Borrowed(arg)))
.collect::<Vec<_>>()
.join(" ");
let script_argv = vec![
"script".to_string(),
"-q".to_string(),
"-c".to_string(),
original_cmd,
log_file.to_string_lossy().to_string(),
];
info!(log_path = %log_file.display(), "Using script to capture snap output via pty");
(script_argv, None) // script handles the log file itself
}
_ => (argv.to_vec(), log_path),
};
let program = &actual_argv[0];
let args = &actual_argv[1..];
let mut cmd = Command::new(program); let mut cmd = Command::new(program);
cmd.args(args); cmd.args(args);
@ -233,6 +273,9 @@ impl ManagedProcess {
cmd.env("WAYLAND_DISPLAY", shepherd_display); cmd.env("WAYLAND_DISPLAY", shepherd_display);
} }
// Chromium-based browsers and Electron apps need this to use the correct password store.
cmd.env("PASSWORD_STORE", "gnome");
// Add custom environment (these can override inherited vars) // Add custom environment (these can override inherited vars)
for (k, v) in env { for (k, v) in env {
cmd.env(k, v); cmd.env(k, v);
@ -243,11 +286,43 @@ impl ManagedProcess {
cmd.current_dir(dir); cmd.current_dir(dir);
} }
// Configure output capture // Configure output handling
// For debugging, inherit stdout/stderr so we can see errors // If actual_log_path is provided, redirect stdout/stderr to the log file
if capture_output { // (For snap apps, we already wrapped with `script` which handles logging)
cmd.stdout(Stdio::piped()); // Otherwise, inherit from parent so we can see child output for debugging
cmd.stderr(Stdio::piped()); if let Some(ref path) = actual_log_path {
// Create parent directory if it doesn't exist
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
warn!(path = %parent.display(), error = %e, "Failed to create log directory");
}
// Open log file for appending (create if doesn't exist)
match File::create(path) {
Ok(file) => {
// Clone file handle for stderr (both point to same file)
let stderr_file = match file.try_clone() {
Ok(f) => f,
Err(e) => {
warn!(path = %path.display(), error = %e, "Failed to clone log file handle");
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
cmd.stdin(Stdio::null());
// Skip to spawn
return Self::spawn_with_cmd(cmd, program, snap_name);
}
};
cmd.stdout(Stdio::from(file));
cmd.stderr(Stdio::from(stderr_file));
info!(path = %path.display(), "Redirecting child output to log file");
}
Err(e) => {
warn!(path = %path.display(), error = %e, "Failed to open log file, inheriting output");
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
}
}
} else { } else {
// Inherit from parent so we can see child output for debugging // Inherit from parent so we can see child output for debugging
cmd.stdout(Stdio::inherit()); cmd.stdout(Stdio::inherit());
@ -256,6 +331,15 @@ impl ManagedProcess {
cmd.stdin(Stdio::null()); cmd.stdin(Stdio::null());
Self::spawn_with_cmd(cmd, program, snap_name)
}
/// Complete the spawn process with the configured command
fn spawn_with_cmd(
mut cmd: Command,
program: &str,
snap_name: Option<String>,
) -> HostResult<Self> {
// Store the command name for later use in killing // Store the command name for later use in killing
let command_name = program.to_string(); let command_name = program.to_string();
@ -467,7 +551,7 @@ mod tests {
let argv = vec!["true".to_string()]; let argv = vec!["true".to_string()];
let env = HashMap::new(); let env = HashMap::new();
let mut proc = ManagedProcess::spawn(&argv, &env, None, false, None).unwrap(); let mut proc = ManagedProcess::spawn(&argv, &env, None, None, None).unwrap();
// Wait for it to complete // Wait for it to complete
let status = proc.wait().unwrap(); let status = proc.wait().unwrap();
@ -479,7 +563,7 @@ mod tests {
let argv = vec!["echo".to_string(), "hello".to_string()]; let argv = vec!["echo".to_string(), "hello".to_string()];
let env = HashMap::new(); let env = HashMap::new();
let mut proc = ManagedProcess::spawn(&argv, &env, None, false, None).unwrap(); let mut proc = ManagedProcess::spawn(&argv, &env, None, None, None).unwrap();
let status = proc.wait().unwrap(); let status = proc.wait().unwrap();
assert!(status.is_success()); assert!(status.is_success());
} }
@ -489,7 +573,7 @@ mod tests {
let argv = vec!["sleep".to_string(), "60".to_string()]; let argv = vec!["sleep".to_string(), "60".to_string()];
let env = HashMap::new(); let env = HashMap::new();
let proc = ManagedProcess::spawn(&argv, &env, None, false, None).unwrap(); let proc = ManagedProcess::spawn(&argv, &env, None, None, None).unwrap();
// Give it a moment to start // Give it a moment to start
std::thread::sleep(std::time::Duration::from_millis(50)); std::thread::sleep(std::time::Duration::from_millis(50));

View file

@ -537,6 +537,27 @@ impl Service {
.get_entry(&entry_id) .get_entry(&entry_id)
.map(|e| e.kind.clone()); .map(|e| e.kind.clone());
// Build spawn options with log path if capture_child_output is enabled
let spawn_options = if eng.policy().service.capture_child_output {
let log_dir = &eng.policy().service.child_log_dir;
// Create log filename: <entry_id>_<session_id>_<timestamp>.log
let timestamp = now.format("%Y%m%d_%H%M%S").to_string();
let log_filename = format!(
"{}_{}.log",
entry_id.as_str().replace(['/', '\\', ' '], "_"),
timestamp
);
let log_path = log_dir.join(log_filename);
shepherd_host_api::SpawnOptions {
capture_stdout: true,
capture_stderr: true,
log_path: Some(log_path),
..Default::default()
}
} else {
shepherd_host_api::SpawnOptions::default()
};
drop(eng); // Release lock before spawning drop(eng); // Release lock before spawning
if let Some(kind) = entry_kind { if let Some(kind) = entry_kind {
@ -544,7 +565,7 @@ impl Service {
.spawn( .spawn(
plan.session_id.clone(), plan.session_id.clone(),
&kind, &kind,
shepherd_host_api::SpawnOptions::default(), spawn_options,
) )
.await .await
{ {

View file

@ -7,6 +7,7 @@ sway
# Wayland session utilities # Wayland session utilities
swayidle swayidle
xdg-desktop-portal-wlr
# Runtime libraries (may be pulled in automatically, but explicit is safer) # Runtime libraries (may be pulled in automatically, but explicit is safer)
libgtk-4-1 libgtk-4-1

View file

@ -1,6 +1,13 @@
# Shepherd Launcher - Kiosk Mode Sway Configuration # Shepherd Launcher - Kiosk Mode Sway Configuration
# This config makes Sway act as a kiosk environment with a custom launcher # This config makes Sway act as a kiosk environment with a custom launcher
# Set up GNOME Keyring for secrets management if available
exec_always sh -lc 'command -v gnome-keyring-daemon >/dev/null && gnome-keyring-daemon --start --components=secrets >/dev/null 2>&1 || true'
# Update environment variables for D-Bus and GNOME Keyring
exec_always dbus-update-activation-environment --systemd \
SSH_AUTH_SOCK GNOME_KEYRING_CONTROL WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE XDG_SESSION_DESKTOP XDG_RUNTIME_DIR
### Variables ### Variables
set $launcher ./target/debug/shepherd-launcher set $launcher ./target/debug/shepherd-launcher
set $hud ./target/debug/shepherd-hud set $hud ./target/debug/shepherd-hud