feat(host/hooks): per-app prep/undo commands (M2b — Sunshine prep-cmd parity)
apple / swift (push) Successful in 1m18s
apple / screenshots (push) Successful in 4m22s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m3s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 5m3s
android / android (push) Successful in 17m6s
arch / build-publish (push) Successful in 16m51s
deb / build-publish (push) Successful in 12m16s
ci / rust (push) Successful in 25m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m17s
windows-host / package (push) Has been cancelled
docker / deploy-docs (push) Failing after 19s
apple / swift (push) Successful in 1m18s
apple / screenshots (push) Successful in 4m22s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m3s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 5m3s
android / android (push) Successful in 17m6s
arch / build-publish (push) Successful in 16m51s
deb / build-publish (push) Successful in 12m16s
ci / rust (push) Successful in 25m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m17s
windows-host / package (push) Has been cancelled
docker / deploy-docs (push) Failing after 19s
`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) <noreply@anthropic.com>
This commit is contained in:
@@ -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": {
|
"title": {
|
||||||
"type": "string"
|
"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": {
|
"title": {
|
||||||
"type": "string"
|
"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": {
|
"Preset": {
|
||||||
"type": "string",
|
"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`]).",
|
"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`]).",
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ pub struct AppEntry {
|
|||||||
/// library ([`crate::library`]). When set, the launch path resolves + launches it against the
|
/// 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.
|
/// host's own library instead of running [`cmd`](Self::cmd). `None` for Desktop / apps.json entries.
|
||||||
pub library_id: Option<String>,
|
pub library_id: Option<String>,
|
||||||
|
/// 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<crate::hooks::PrepCmd>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_path() -> Option<std::path::PathBuf> {
|
fn config_path() -> Option<std::path::PathBuf> {
|
||||||
@@ -68,6 +71,12 @@ fn base_catalog() -> Vec<AppEntry> {
|
|||||||
.and_then(parse_compositor),
|
.and_then(parse_compositor),
|
||||||
cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from),
|
cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from),
|
||||||
library_id: None,
|
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();
|
.collect();
|
||||||
@@ -88,6 +97,7 @@ fn base_catalog() -> Vec<AppEntry> {
|
|||||||
compositor: None,
|
compositor: None,
|
||||||
cmd: None,
|
cmd: None,
|
||||||
library_id: None,
|
library_id: None,
|
||||||
|
prep: Vec::new(),
|
||||||
}];
|
}];
|
||||||
if which("gamescope") {
|
if which("gamescope") {
|
||||||
if which("steam") {
|
if which("steam") {
|
||||||
@@ -97,6 +107,7 @@ fn base_catalog() -> Vec<AppEntry> {
|
|||||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||||
cmd: Some("steam -gamepadui".into()),
|
cmd: Some("steam -gamepadui".into()),
|
||||||
library_id: None,
|
library_id: None,
|
||||||
|
prep: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if which("vkcube") {
|
if which("vkcube") {
|
||||||
@@ -106,6 +117,7 @@ fn base_catalog() -> Vec<AppEntry> {
|
|||||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||||
cmd: Some("vkcube".into()),
|
cmd: Some("vkcube".into()),
|
||||||
library_id: None,
|
library_id: None,
|
||||||
|
prep: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,6 +151,7 @@ fn append_library(apps: &mut Vec<AppEntry>) {
|
|||||||
compositor: None, // auto-detect the desktop session (Windows ignores the compositor)
|
compositor: None, // auto-detect the desktop session (Windows ignores the compositor)
|
||||||
cmd: None,
|
cmd: None,
|
||||||
library_id: Some(g.id),
|
library_id: Some(g.id),
|
||||||
|
prep: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,6 +255,7 @@ mod tests {
|
|||||||
compositor: None,
|
compositor: None,
|
||||||
cmd: None,
|
cmd: None,
|
||||||
library_id: None,
|
library_id: None,
|
||||||
|
prep: Vec::new(),
|
||||||
}];
|
}];
|
||||||
append_library(&mut apps);
|
append_library(&mut apps);
|
||||||
let ids: Vec<u32> = apps.iter().map(|a| a.id).collect();
|
let ids: Vec<u32> = apps.iter().map(|a| a.id).collect();
|
||||||
|
|||||||
@@ -163,6 +163,20 @@ fn run(
|
|||||||
// `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the
|
// `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).
|
// output is released when this capturer drops at stream end (RAII via its keepalive).
|
||||||
if crate::config::config().video_source.as_deref() == Some("virtual") {
|
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
|
// 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,
|
// (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.
|
// exactly like the native plane), create a virtual output at the client mode, and capture it.
|
||||||
|
|||||||
@@ -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.
|
/// 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)]
|
#[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::io::Write;
|
||||||
use std::os::unix::process::CommandExt;
|
use std::os::unix::process::CommandExt;
|
||||||
let mut c = std::process::Command::new("/bin/sh");
|
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,
|
Ok(ch) => ch,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(cmd = %cmd, error = %e, "hook command failed to launch");
|
tracing::error!(cmd = %cmd, error = %e, "hook command failed to launch");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(mut stdin) = child.stdin.take() {
|
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() {
|
if !status.success() {
|
||||||
tracing::warn!(cmd = %cmd, %status, "hook command exited non-zero");
|
tracing::warn!(cmd = %cmd, %status, "hook command exited non-zero");
|
||||||
}
|
}
|
||||||
return;
|
return status.success();
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
if Instant::now() >= deadline {
|
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"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
let _ = child.wait(); // reap — never leave a zombie
|
let _ = child.wait(); // reap — never leave a zombie
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(100));
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(cmd = %cmd, error = %e, "hook command wait failed");
|
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
|
/// 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).
|
/// spawn with the full Unix-style context (env + stdin).
|
||||||
#[cfg(windows)]
|
#[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;
|
use std::io::Write;
|
||||||
let stamp = format!(
|
let stamp = format!(
|
||||||
"pf-hook-{}-{}.json",
|
"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.
|
// No child handle on this path — wait out the timeout, then clean the temp file.
|
||||||
std::thread::sleep(timeout);
|
std::thread::sleep(timeout);
|
||||||
let _ = std::fs::remove_file(&json_path);
|
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) => {
|
Err(e) => {
|
||||||
tracing::debug!(error = %format!("{e:#}"),
|
tracing::debug!(error = %format!("{e:#}"),
|
||||||
"interactive-session spawn unavailable — running hook in-console");
|
"interactive-session spawn unavailable — running hook in-console");
|
||||||
|
let mut ok = false;
|
||||||
let mut c = std::process::Command::new("cmd.exe");
|
let mut c = std::process::Command::new("cmd.exe");
|
||||||
c.arg("/C")
|
c.arg("/C")
|
||||||
.arg(cmd)
|
.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 _ = stdin.write_all(event_json.as_bytes());
|
||||||
}
|
}
|
||||||
let deadline = Instant::now() + timeout;
|
let deadline = Instant::now() + timeout;
|
||||||
while child.try_wait().ok().flatten().is_none() {
|
loop {
|
||||||
if Instant::now() >= deadline {
|
match child.try_wait().ok().flatten() {
|
||||||
tracing::warn!(cmd = %cmd, "hook command timed out — killing it");
|
Some(status) => {
|
||||||
let _ = child.kill();
|
ok = status.success();
|
||||||
let _ = child.wait();
|
break;
|
||||||
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"),
|
Err(e) => tracing::error!(cmd = %cmd, error = %e, "hook command failed to launch"),
|
||||||
}
|
}
|
||||||
let _ = std::fs::remove_file(&json_path);
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
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
|
// ------------------------------------------------------------------------- tests
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn ownership_check_refuses_world_writable_scripts() {
|
fn ownership_check_refuses_world_writable_scripts() {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ pub struct CustomEntry {
|
|||||||
pub art: Artwork,
|
pub art: Artwork,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub launch: Option<LaunchSpec>,
|
pub launch: Option<LaunchSpec>,
|
||||||
|
/// 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<crate::hooks::PrepCmd>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body to create or replace a custom entry (no `id` — the host owns it).
|
/// 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,
|
pub art: Artwork,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub launch: Option<LaunchSpec>,
|
pub launch: Option<LaunchSpec>,
|
||||||
|
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
|
||||||
|
#[serde(default)]
|
||||||
|
pub prep: Vec<crate::hooks::PrepCmd>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<CustomEntry> for GameEntry {
|
impl From<CustomEntry> for GameEntry {
|
||||||
@@ -89,6 +96,7 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
|||||||
title: input.title,
|
title: input.title,
|
||||||
art: input.art,
|
art: input.art,
|
||||||
launch: input.launch,
|
launch: input.launch,
|
||||||
|
prep: input.prep,
|
||||||
};
|
};
|
||||||
entries.push(entry.clone());
|
entries.push(entry.clone());
|
||||||
save_custom(&entries)?;
|
save_custom(&entries)?;
|
||||||
@@ -105,6 +113,7 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result<Option<CustomEntry>
|
|||||||
slot.title = input.title;
|
slot.title = input.title;
|
||||||
slot.art = input.art;
|
slot.art = input.art;
|
||||||
slot.launch = input.launch;
|
slot.launch = input.launch;
|
||||||
|
slot.prep = input.prep;
|
||||||
let updated = slot.clone();
|
let updated = slot.clone();
|
||||||
save_custom(&entries)?;
|
save_custom(&entries)?;
|
||||||
emit_changed();
|
emit_changed();
|
||||||
@@ -124,6 +133,19 @@ pub fn delete_custom(id: &str) -> Result<bool> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The prep/undo steps for a library id — `custom:<id>` 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<crate::hooks::PrepCmd> {
|
||||||
|
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
|
/// 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).
|
/// `source: "manual"` (RFC §4; a provider id once the provider API of RFC §8 lands).
|
||||||
fn emit_changed() {
|
fn emit_changed() {
|
||||||
@@ -151,6 +173,7 @@ mod tests {
|
|||||||
title: "My ROM".into(),
|
title: "My ROM".into(),
|
||||||
art: Artwork::default(),
|
art: Artwork::default(),
|
||||||
launch: None,
|
launch: None,
|
||||||
|
prep: Vec::new(),
|
||||||
}
|
}
|
||||||
.into();
|
.into();
|
||||||
assert_eq!(g.id, "custom:abc123");
|
assert_eq!(g.id, "custom:abc123");
|
||||||
|
|||||||
@@ -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)
|
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
|
// "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.
|
// bpp pin the data plane re-resolves on a mid-stream mode switch.
|
||||||
|
|||||||
Reference in New Issue
Block a user