From 63efe0ecd5bb21993444972cf9dee049b2940e9f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 22:02:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(host/hooks):=20per-app=20prep/undo=20comma?= =?UTF-8?q?nds=20(M2b=20=E2=80=94=20Sunshine=20prep-cmd=20parity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prep: [{"do": …, "undo": …}]` arrays on GameStream apps.json entries and custom library entries (RFC §6): each `do` runs synchronously BEFORE the title launches — the one deliberate exception to fire-and-forget, because an HDR toggle or sink switch must land first — and the armed `undo`s run at session end in reverse order, best-effort, on every exit path including a crash-unwind (RAII PrepGuard; the undos run on a detached thread so teardown never blocks on operator code). - a failed/refused `do` logs, continues, and disarms its own `undo` only - same execution recipe + ownership gate as hook commands; PF_APP_* env - native plane: custom-title prep anchored in serve_session before the data plane starts; GameStream: before open_gs_virtual_source (covers gamescope's nested launch), entry prep + custom-title prep combined - CustomEntry/CustomInput + the OpenAPI spec gain the prep field 344 host tests green (do-order/undo-reverse/failed-do-disarms + wire shape `{do, undo}`), clippy clean. On-glass with a real client session owed. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/openapi.json | 34 +++ crates/punktfunk-host/src/gamestream/apps.rs | 14 ++ .../punktfunk-host/src/gamestream/stream.rs | 14 ++ crates/punktfunk-host/src/hooks.rs | 193 ++++++++++++++++-- crates/punktfunk-host/src/library/custom.rs | 23 +++ crates/punktfunk-host/src/native.rs | 11 + 6 files changed, 276 insertions(+), 13 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index bb6eb548..63d458a1 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -2616,6 +2616,13 @@ } ] }, + "prep": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PrepCmd" + }, + "description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])." + }, "title": { "type": "string" } @@ -2641,6 +2648,13 @@ } ] }, + "prep": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PrepCmd" + }, + "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." + }, "title": { "type": "string" } @@ -3984,6 +3998,26 @@ } } }, + "PrepCmd": { + "type": "object", + "description": "One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs\n**synchronously before the app launches** (an HDR toggle or a MangoHud env change must land\nfirst), `undo` runs at session end — reverse order across steps, best-effort, on every exit\npath including a crash-unwind (RAII via [`PrepGuard`]).", + "required": [ + "do" + ], + "properties": { + "do": { + "type": "string", + "description": "Command run before launch. Same execution recipe and ownership checks as hook `run`\ncommands (event-less: stdin is empty JSON, env carries the `PF_APP_*` context)." + }, + "undo": { + "type": [ + "string", + "null" + ], + "description": "Command run after the session ends. Skipped when its `do` failed (it never took effect)." + } + } + }, "Preset": { "type": "string", "description": "A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any\nother preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).", diff --git a/crates/punktfunk-host/src/gamestream/apps.rs b/crates/punktfunk-host/src/gamestream/apps.rs index 74cfc86b..a9b93b0c 100644 --- a/crates/punktfunk-host/src/gamestream/apps.rs +++ b/crates/punktfunk-host/src/gamestream/apps.rs @@ -21,6 +21,9 @@ pub struct AppEntry { /// library ([`crate::library`]). When set, the launch path resolves + launches it against the /// host's own library instead of running [`cmd`](Self::cmd). `None` for Desktop / apps.json entries. pub library_id: Option, + /// Per-app prep/undo steps (RFC §6, Sunshine `prep-cmd` parity): each `do` runs before the + /// app launches, each `undo` at stream end in reverse order (see [`crate::hooks::run_prep`]). + pub prep: Vec, } fn config_path() -> Option { @@ -68,6 +71,12 @@ fn base_catalog() -> Vec { .and_then(parse_compositor), cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from), library_id: None, + // `"prep": [{"do": …, "undo": …}, …]` — optional; a malformed + // array is ignored (the entry still launches, just unprepped). + prep: it + .get("prep") + .and_then(|p| serde_json::from_value(p.clone()).ok()) + .unwrap_or_default(), }) }) .collect(); @@ -88,6 +97,7 @@ fn base_catalog() -> Vec { compositor: None, cmd: None, library_id: None, + prep: Vec::new(), }]; if which("gamescope") { if which("steam") { @@ -97,6 +107,7 @@ fn base_catalog() -> Vec { compositor: Some(crate::vdisplay::Compositor::Gamescope), cmd: Some("steam -gamepadui".into()), library_id: None, + prep: Vec::new(), }); } if which("vkcube") { @@ -106,6 +117,7 @@ fn base_catalog() -> Vec { compositor: Some(crate::vdisplay::Compositor::Gamescope), cmd: Some("vkcube".into()), library_id: None, + prep: Vec::new(), }); } } @@ -139,6 +151,7 @@ fn append_library(apps: &mut Vec) { compositor: None, // auto-detect the desktop session (Windows ignores the compositor) cmd: None, library_id: Some(g.id), + prep: Vec::new(), }); } } @@ -242,6 +255,7 @@ mod tests { compositor: None, cmd: None, library_id: None, + prep: Vec::new(), }]; append_library(&mut apps); let ids: Vec = apps.iter().map(|a| a.id).collect(); diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index 15ebbe6e..089edb09 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -163,6 +163,20 @@ fn run( // `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the // output is released when this capturer drops at stream end (RAII via its keepalive). if crate::config::config().video_source.as_deref() == Some("virtual") { + // Per-app prep steps (RFC §6): the entry's own `prep` plus a custom library title's, + // run synchronously BEFORE the virtual output opens or anything launches (an HDR + // toggle / sink switch must land first — and gamescope's nested launch happens inside + // `open_gs_virtual_source`). The guard's drop runs the undos at stream end — reverse + // order, best-effort, on every exit path including a panic-unwind. + let mut prep_cmds = app.map(|a| a.prep.clone()).unwrap_or_default(); + if let Some(lib_id) = app.and_then(|a| a.library_id.as_deref()) { + prep_cmds.extend(crate::library::prep_for(lib_id)); + } + let prep_env = [( + "PF_APP_TITLE".to_string(), + app.map(|a| a.title.clone()).unwrap_or_default(), + )]; + let _prep = (!prep_cmds.is_empty()).then(|| crate::hooks::run_prep(&prep_cmds, &prep_env)); // Open the virtual-display source: pick the live compositor, normalize the session env // (apply_session_env/apply_input_env — gamescope ATTACH/resize + KWin/Mutter retargeting, // exactly like the native plane), create a virtual output at the client mode, and capture it. diff --git a/crates/punktfunk-host/src/hooks.rs b/crates/punktfunk-host/src/hooks.rs index 162a0cc3..f45d8741 100644 --- a/crates/punktfunk-host/src/hooks.rs +++ b/crates/punktfunk-host/src/hooks.rs @@ -423,8 +423,15 @@ fn exec_path_check(_cmd: &str) -> Result<(), String> { } /// Run one hook command to completion (or timeout), blocking the reaper thread it runs on. +/// Returns whether the command ran to completion successfully (exit 0) — the prep machinery +/// gates each step's `undo` on it. #[cfg(unix)] -fn run_hook_process(cmd: &str, event_json: &str, env: &[(String, String)], timeout: Duration) { +fn run_hook_process( + cmd: &str, + event_json: &str, + env: &[(String, String)], + timeout: Duration, +) -> bool { use std::io::Write; use std::os::unix::process::CommandExt; let mut c = std::process::Command::new("/bin/sh"); @@ -440,7 +447,7 @@ fn run_hook_process(cmd: &str, event_json: &str, env: &[(String, String)], timeo Ok(ch) => ch, Err(e) => { tracing::error!(cmd = %cmd, error = %e, "hook command failed to launch"); - return; + return false; } }; if let Some(mut stdin) = child.stdin.take() { @@ -454,7 +461,7 @@ fn run_hook_process(cmd: &str, event_json: &str, env: &[(String, String)], timeo if !status.success() { tracing::warn!(cmd = %cmd, %status, "hook command exited non-zero"); } - return; + return status.success(); } Ok(None) => { if Instant::now() >= deadline { @@ -469,13 +476,13 @@ fn run_hook_process(cmd: &str, event_json: &str, env: &[(String, String)], timeo #[cfg(not(target_os = "linux"))] let _ = child.kill(); let _ = child.wait(); // reap — never leave a zombie - return; + return false; } std::thread::sleep(Duration::from_millis(100)); } Err(e) => { tracing::warn!(cmd = %cmd, error = %e, "hook command wait failed"); - return; + return false; } } } @@ -487,7 +494,12 @@ fn run_hook_process(cmd: &str, event_json: &str, env: &[(String, String)], timeo /// appended as the command's last argument. A console-mode host (dev) falls back to a plain /// spawn with the full Unix-style context (env + stdin). #[cfg(windows)] -fn run_hook_process(cmd: &str, event_json: &str, env: &[(String, String)], timeout: Duration) { +fn run_hook_process( + cmd: &str, + event_json: &str, + env: &[(String, String)], + timeout: Duration, +) -> bool { use std::io::Write; let stamp = format!( "pf-hook-{}-{}.json", @@ -508,10 +520,14 @@ fn run_hook_process(cmd: &str, event_json: &str, env: &[(String, String)], timeo // No child handle on this path — wait out the timeout, then clean the temp file. std::thread::sleep(timeout); let _ = std::fs::remove_file(&json_path); + // Detached in the user session: completion/exit status is unobservable here — + // report "ran" (prep `undo`s stay armed). + true } Err(e) => { tracing::debug!(error = %format!("{e:#}"), "interactive-session spawn unavailable — running hook in-console"); + let mut ok = false; let mut c = std::process::Command::new("cmd.exe"); c.arg("/C") .arg(cmd) @@ -525,19 +541,26 @@ fn run_hook_process(cmd: &str, event_json: &str, env: &[(String, String)], timeo let _ = stdin.write_all(event_json.as_bytes()); } let deadline = Instant::now() + timeout; - while child.try_wait().ok().flatten().is_none() { - if Instant::now() >= deadline { - tracing::warn!(cmd = %cmd, "hook command timed out — killing it"); - let _ = child.kill(); - let _ = child.wait(); - break; + loop { + match child.try_wait().ok().flatten() { + Some(status) => { + ok = status.success(); + break; + } + None if Instant::now() >= deadline => { + tracing::warn!(cmd = %cmd, "hook command timed out — killing it"); + let _ = child.kill(); + let _ = child.wait(); + break; + } + None => std::thread::sleep(Duration::from_millis(100)), } - std::thread::sleep(Duration::from_millis(100)); } } Err(e) => tracing::error!(cmd = %cmd, error = %e, "hook command failed to launch"), } let _ = std::fs::remove_file(&json_path); + ok } } } @@ -604,6 +627,88 @@ fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) { } } +// ------------------------------------------------------------------------- per-app prep/undo + +/// One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs +/// **synchronously before the app launches** (an HDR toggle or a MangoHud env change must land +/// first), `undo` runs at session end — reverse order across steps, best-effort, on every exit +/// path including a crash-unwind (RAII via [`PrepGuard`]). +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq)] +pub struct PrepCmd { + /// Command run before launch. Same execution recipe and ownership checks as hook `run` + /// commands (event-less: stdin is empty JSON, env carries the `PF_APP_*` context). + #[serde(rename = "do")] + pub run: String, + /// Command run after the session ends. Skipped when its `do` failed (it never took effect). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub undo: Option, +} + +/// Holds the armed `undo` commands for one session's prep steps; dropping it (session end, +/// error return, panic-unwind) runs them in reverse order on a detached thread — teardown +/// never blocks on operator code. +#[must_use = "dropping the guard immediately runs the undo commands"] +pub struct PrepGuard { + undo: Vec, + env: Vec<(String, String)>, +} + +/// Run a title's prep steps **synchronously, in order** (the caller is a launch path — this is +/// the one deliberate exception to fire-and-forget, because prep exists to happen *before* the +/// game). Each step gets the default hook timeout and the same ownership gate as hook +/// commands; a failed/refused `do` logs and continues (best-effort), and its `undo` stays +/// disarmed. Returns the guard that runs the armed `undo`s at drop. +pub fn run_prep(cmds: &[PrepCmd], env: &[(String, String)]) -> PrepGuard { + let timeout = Duration::from_secs(u64::from(DEFAULT_TIMEOUT_S)); + let mut undo = Vec::new(); + for c in cmds { + let cmd = c.run.trim(); + if cmd.is_empty() { + continue; + } + if let Err(e) = exec_path_check(cmd) { + tracing::error!(cmd = %cmd, "REFUSING prep command — {e}"); + continue; + } + tracing::info!(cmd = %cmd, "prep: running"); + if run_hook_process(cmd, "{}", env, timeout) { + if let Some(u) = c.undo.as_deref().filter(|u| !u.trim().is_empty()) { + undo.push(u.to_string()); + } + } else if c.undo.is_some() { + tracing::warn!(cmd = %cmd, "prep step failed — its undo is skipped"); + } + } + PrepGuard { + undo, + env: env.to_vec(), + } +} + +impl Drop for PrepGuard { + fn drop(&mut self) { + if self.undo.is_empty() { + return; + } + let undo = std::mem::take(&mut self.undo); + let env = std::mem::take(&mut self.env); + let timeout = Duration::from_secs(u64::from(DEFAULT_TIMEOUT_S)); + // Detached: the drop site may be an async task or a panic-unwind — session teardown + // must not block on operator commands. Order (reverse of `do`) is preserved because + // the one thread runs them sequentially. + std::thread::spawn(move || { + for cmd in undo.iter().rev() { + if let Err(e) = exec_path_check(cmd) { + tracing::error!(cmd = %cmd, "REFUSING prep undo command — {e}"); + continue; + } + tracing::info!(cmd = %cmd, "prep: running undo"); + run_hook_process(cmd, "{}", &env, timeout); + } + }); + } +} + // ------------------------------------------------------------------------- tests #[cfg(test)] @@ -816,6 +921,68 @@ mod tests { ); } + /// Prep semantics end to end: `do`s run in order before the guard exists, armed `undo`s run + /// in REVERSE order at drop, and a failed `do` disarms its own `undo` only. + #[cfg(unix)] + #[test] + fn prep_runs_do_in_order_and_undo_in_reverse() { + let out = std::env::temp_dir().join(format!( + "pf-prep-test-{}-{:p}.txt", + std::process::id(), + &0u8 as *const u8 + )); + let _ = std::fs::remove_file(&out); + let step = |do_tag: &str, undo_tag: Option<&str>| PrepCmd { + run: format!("echo {do_tag} >> {}", out.display()), + undo: undo_tag.map(|t| format!("echo {t} >> {}", out.display())), + }; + let cmds = vec![ + step("do-a", Some("undo-a")), + step("do-b", Some("undo-b")), + // A failing `do` must not arm its undo. + PrepCmd { + run: "false".into(), + undo: Some(format!("echo undo-never >> {}", out.display())), + }, + ]; + let guard = run_prep(&cmds, &[]); + let text = std::fs::read_to_string(&out).expect("prep steps ran synchronously"); + assert_eq!(text, "do-a\ndo-b\n", "dos run in order, before launch"); + + drop(guard); + // The undo thread is detached — poll for its completion. + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let text = std::fs::read_to_string(&out).unwrap_or_default(); + if text.lines().count() >= 4 { + assert_eq!( + text, "do-a\ndo-b\nundo-b\nundo-a\n", + "undos run in reverse; the failed step's undo is skipped" + ); + break; + } + assert!(Instant::now() < deadline, "undo thread never ran: {text}"); + std::thread::sleep(Duration::from_millis(50)); + } + // Give the skipped-undo a beat to (wrongly) appear, then assert it didn't. + std::thread::sleep(Duration::from_millis(200)); + assert!(!std::fs::read_to_string(&out) + .unwrap() + .contains("undo-never")); + let _ = std::fs::remove_file(&out); + } + + #[test] + fn prep_cmd_wire_shape() { + // The RFC's `{ "do": …, "undo": … }` spelling is the wire contract. + let c: PrepCmd = serde_json::from_str(r#"{"do":"a","undo":"b"}"#).unwrap(); + assert_eq!(c.run, "a"); + assert_eq!(c.undo.as_deref(), Some("b")); + let c: PrepCmd = serde_json::from_str(r#"{"do":"a"}"#).unwrap(); + assert!(c.undo.is_none()); + assert_eq!(serde_json::to_string(&c).unwrap(), r#"{"do":"a"}"#); + } + #[cfg(unix)] #[test] fn ownership_check_refuses_world_writable_scripts() { diff --git a/crates/punktfunk-host/src/library/custom.rs b/crates/punktfunk-host/src/library/custom.rs index 09bc0a9f..954748f3 100644 --- a/crates/punktfunk-host/src/library/custom.rs +++ b/crates/punktfunk-host/src/library/custom.rs @@ -14,6 +14,10 @@ pub struct CustomEntry { pub art: Artwork, #[serde(default, skip_serializing_if = "Option::is_none")] pub launch: Option, + /// Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each + /// `undo` at session end in reverse order (see [`crate::hooks::run_prep`]). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub prep: Vec, } /// Request body to create or replace a custom entry (no `id` — the host owns it). @@ -24,6 +28,9 @@ pub struct CustomInput { pub art: Artwork, #[serde(default)] pub launch: Option, + /// Per-title prep/undo steps — commands run as the host user; operator-privileged config. + #[serde(default)] + pub prep: Vec, } impl From for GameEntry { @@ -89,6 +96,7 @@ pub fn add_custom(input: CustomInput) -> Result { title: input.title, art: input.art, launch: input.launch, + prep: input.prep, }; entries.push(entry.clone()); save_custom(&entries)?; @@ -105,6 +113,7 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result slot.title = input.title; slot.art = input.art; slot.launch = input.launch; + slot.prep = input.prep; let updated = slot.clone(); save_custom(&entries)?; emit_changed(); @@ -124,6 +133,19 @@ pub fn delete_custom(id: &str) -> Result { Ok(true) } +/// The prep/undo steps for a library id — `custom:` entries only (the other stores have no +/// per-title config surface; a GameStream `apps.json` entry carries its own `prep` instead). +pub fn prep_for(library_id: &str) -> Vec { + let Some(id) = library_id.strip_prefix("custom:") else { + return Vec::new(); + }; + load_custom() + .into_iter() + .find(|e| e.id == id) + .map(|e| e.prep) + .unwrap_or_default() +} + /// The custom-entry mutations are the only library writes today, all operator-driven — hence /// `source: "manual"` (RFC §4; a provider id once the provider API of RFC §8 lands). fn emit_changed() { @@ -151,6 +173,7 @@ mod tests { title: "My ROM".into(), art: Artwork::default(), launch: None, + prep: Vec::new(), } .into(); assert_eq!(g.id, "custom:abc123"); diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 7383e38d..7078c1fc 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -1110,6 +1110,17 @@ async fn serve_session( } } }); + // Per-title prep steps (RFC §6) for a launched CUSTOM library title: run synchronously + // before the data plane starts (so before the display opens and the title spawns); the + // guard's drop — any serve_session exit — runs the undos in reverse, best-effort. + // `block_in_place`: prep is blocking operator code and this is a multi-thread runtime; + // the closure only runs when the title actually has prep steps. + let _prep = hello.launch.as_deref().and_then(|id| { + let cmds = crate::library::prep_for(id); + let env = [("PF_APP_ID".to_string(), id.to_string())]; + (!cmds.is_empty()) + .then(|| tokio::task::block_in_place(|| crate::hooks::run_prep(&cmds, &env))) + }); let bitrate_kbps = welcome.bitrate_kbps; // resolved encoder bitrate (Hello clamped, or default) // "Automatic" request: the resolved rate is a host default — for PyroWave a per-mode // bpp pin the data plane re-resolves on a mid-stream mode switch.