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

`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:
2026-07-16 22:02:35 +02:00
parent 384f8e00aa
commit 63efe0ecd5
6 changed files with 276 additions and 13 deletions
+34
View File
@@ -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.
+180 -13
View File
@@ -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");
+11
View File
@@ -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.