diff --git a/Cargo.lock b/Cargo.lock index a74aa49..aa53fc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1187,6 +1187,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-escape" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f" + [[package]] name = "shepherd-api" version = "0.1.0" @@ -1252,6 +1258,7 @@ dependencies = [ "dirs", "nix", "serde", + "shell-escape", "shepherd-api", "shepherd-host-api", "shepherd-util", diff --git a/README.md b/README.md index 1314839..9bc59af 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # shepherd-launcher -A child-friendly, parent-guided launcher for Wayland, allowing supervised -access to applications and content that you define. +A child-friendly, parent-guided desktop environment *alternative* for Wayland, +allowing supervised access to applications and content that you define. Its primary goal is to return control of child-focused computing to parents, not software or hardware vendors, by providing: diff --git a/config.example.toml b/config.example.toml index 3be1ade..eded285 100644 --- a/config.example.toml +++ b/config.example.toml @@ -12,6 +12,12 @@ config_version = 1 # log_dir = "~/.local/state/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: _.log +# capture_child_output = true +# child_log_dir = "~/.local/state/shepherdd/sessions" + # Default max run duration if not specified per entry (1 hour) # Set to 0 for unlimited (no time limit) default_max_run_seconds = 3600 @@ -308,13 +314,17 @@ max_run_seconds = 0 # Unlimited: sleep/study aid daily_quota_seconds = 0 # Unlimited cooldown_seconds = 0 # No cooldown -# Example: Disabled entry +# Terminal for debugging only [[entries]] -id = "disabled-game" -label = "Game Under Maintenance" -disabled = true -disabled_reason = "This game is being updated" +id = "terminal" +label = "Terminal" +icon = "utilities-terminal" +disabled = true # Typically disabled, since it's: +disabled_reason = "For debugging only" [entries.kind] type = "process" -command = "/bin/false" +command = "ptyxis" + +[entries.availability] +always = true diff --git a/crates/shepherd-config/src/policy.rs b/crates/shepherd-config/src/policy.rs index 4579908..2389e52 100644 --- a/crates/shepherd-config/src/policy.rs +++ b/crates/shepherd-config/src/policy.rs @@ -77,17 +77,28 @@ pub struct ServiceConfig { pub socket_path: PathBuf, pub log_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 { 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 { socket_path: raw .socket_path .unwrap_or_else(socket_path_without_env), - log_dir: raw - .log_dir - .unwrap_or_else(default_log_dir), + log_dir, + capture_child_output: raw.capture_child_output, + child_log_dir, data_dir: raw .data_dir .unwrap_or_else(default_data_dir), @@ -97,10 +108,13 @@ impl ServiceConfig { impl Default for ServiceConfig { fn default() -> Self { + let log_dir = default_log_dir(); Self { 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(), + capture_child_output: false, } } } diff --git a/crates/shepherd-config/src/schema.rs b/crates/shepherd-config/src/schema.rs index 99b939f..d1b5370 100644 --- a/crates/shepherd-config/src/schema.rs +++ b/crates/shepherd-config/src/schema.rs @@ -31,6 +31,14 @@ pub struct RawServiceConfig { /// Data directory for store (default: $XDG_DATA_HOME/shepherdd) pub data_dir: Option, + /// 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, + /// Default warning thresholds (can be overridden per entry) pub default_warnings: Option>, diff --git a/crates/shepherd-host-linux/Cargo.toml b/crates/shepherd-host-linux/Cargo.toml index 51f3048..4459e39 100644 --- a/crates/shepherd-host-linux/Cargo.toml +++ b/crates/shepherd-host-linux/Cargo.toml @@ -16,6 +16,7 @@ tokio = { workspace = true } nix = { workspace = true } async-trait = "0.1" dirs = "5.0" +shell-escape = "0.1" [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/shepherd-host-linux/src/adapter.rs b/crates/shepherd-host-linux/src/adapter.rs index a70aa23..a8bd9dd 100644 --- a/crates/shepherd-host-linux/src/adapter.rs +++ b/crates/shepherd-host-linux/src/adapter.rs @@ -191,7 +191,7 @@ impl HostAdapter for LinuxHost { &argv, &env, cwd.as_ref(), - options.capture_stdout || options.capture_stderr, + options.log_path.clone(), snap_name.clone(), )?; diff --git a/crates/shepherd-host-linux/src/process.rs b/crates/shepherd-host-linux/src/process.rs index 72cd8b5..1abfbdd 100644 --- a/crates/shepherd-host-linux/src/process.rs +++ b/crates/shepherd-host-linux/src/process.rs @@ -3,7 +3,9 @@ use nix::sys::signal::{self, Signal}; use nix::unistd::Pid; use std::collections::HashMap; +use std::fs::File; use std::os::unix::process::CommandExt; +use std::path::PathBuf; use std::process::{Child, Command, Stdio}; 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 /// 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( argv: &[String], env: &HashMap, cwd: Option<&std::path::PathBuf>, - capture_output: bool, + log_path: Option, snap_name: Option, ) -> HostResult { if argv.is_empty() { return Err(HostError::SpawnFailed("Empty argv".into())); } - let program = &argv[0]; - let args = &argv[1..]; + // For snap apps with log capture, wrap with `script` to capture all child output + // 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::>() + .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); cmd.args(args); @@ -233,6 +273,9 @@ impl ManagedProcess { 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) for (k, v) in env { cmd.env(k, v); @@ -243,11 +286,43 @@ impl ManagedProcess { cmd.current_dir(dir); } - // Configure output capture - // For debugging, inherit stdout/stderr so we can see errors - if capture_output { - cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::piped()); + // Configure output handling + // If actual_log_path is provided, redirect stdout/stderr to the log file + // (For snap apps, we already wrapped with `script` which handles logging) + // Otherwise, inherit from parent so we can see child output for debugging + 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 { // Inherit from parent so we can see child output for debugging cmd.stdout(Stdio::inherit()); @@ -256,6 +331,15 @@ impl ManagedProcess { 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, + ) -> HostResult { // Store the command name for later use in killing let command_name = program.to_string(); @@ -467,7 +551,7 @@ mod tests { let argv = vec!["true".to_string()]; 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 let status = proc.wait().unwrap(); @@ -479,7 +563,7 @@ mod tests { let argv = vec!["echo".to_string(), "hello".to_string()]; 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(); assert!(status.is_success()); } @@ -489,7 +573,7 @@ mod tests { let argv = vec!["sleep".to_string(), "60".to_string()]; 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 std::thread::sleep(std::time::Duration::from_millis(50)); diff --git a/crates/shepherdd/src/main.rs b/crates/shepherdd/src/main.rs index 01ceb40..96816ed 100644 --- a/crates/shepherdd/src/main.rs +++ b/crates/shepherdd/src/main.rs @@ -537,6 +537,27 @@ impl Service { .get_entry(&entry_id) .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: __.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 if let Some(kind) = entry_kind { @@ -544,7 +565,7 @@ impl Service { .spawn( plan.session_id.clone(), &kind, - shepherd_host_api::SpawnOptions::default(), + spawn_options, ) .await { diff --git a/scripts/deps/run.pkgs b/scripts/deps/run.pkgs index 49a2ce2..004360f 100644 --- a/scripts/deps/run.pkgs +++ b/scripts/deps/run.pkgs @@ -7,6 +7,7 @@ sway # Wayland session utilities swayidle +xdg-desktop-portal-wlr # Runtime libraries (may be pulled in automatically, but explicit is safer) libgtk-4-1 diff --git a/sway.conf b/sway.conf index f29153d..9a2fac8 100644 --- a/sway.conf +++ b/sway.conf @@ -1,6 +1,13 @@ # Shepherd Launcher - Kiosk Mode Sway Configuration # 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 set $launcher ./target/debug/shepherd-launcher set $hud ./target/debug/shepherd-hud