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
@@ -14,6 +14,10 @@ pub struct CustomEntry {
pub art: Artwork,
#[serde(default, skip_serializing_if = "Option::is_none")]
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).
@@ -24,6 +28,9 @@ pub struct CustomInput {
pub art: Artwork,
#[serde(default)]
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 {
@@ -89,6 +96,7 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
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<Option<CustomEntry>
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<bool> {
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
/// `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");