feat(launch): punktfunk/1 launch integration — client picks a title, host runs it
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 30s
apple / swift (push) Successful in 1m17s
ci / rust (push) Successful in 2m6s
ci / bench (push) Successful in 1m34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m23s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m53s
docker / deploy-docs (push) Successful in 40s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m55s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 30s
apple / swift (push) Successful in 1m17s
ci / rust (push) Successful in 2m6s
ci / bench (push) Successful in 1m34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m23s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m53s
docker / deploy-docs (push) Successful in 40s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m55s
Plan step 4 (plumbing + host behavior). A client can ask the host to launch a library title on connect; the host resolves it against ITS OWN library and runs it in the session — the client sends only the store-qualified id, never a command, so a remote peer can't inject one. - Protocol (quic.rs): `Hello.launch: Option<String>` (the GameEntry id). Appended after `name`; when launch is present but name absent, a zero-length name placeholder keeps the offset deterministic — so a Hello with neither field stays byte-identical to the bitrate-era 26-byte form (test-asserted). Old peers ignore it; new hosts decode None from old clients. Round-trip + back-compat + truncation tests. - Host: `library::launch_command(id)` resolves id → command via the host's own library — `steam_appid` → `steam steam://rungameid/<appid>` (appid validated as digits, the only client-influenced part), `command` → the host-stored command verbatim (trusted, never from the client). m3.rs sets PUNKTFUNK_GAMESCOPE_APP from it before bringup, exactly as the GameStream /launch path does (one session at a time). Unit-tested incl. an injection-attempt guard. Takes effect on the bare-spawn gamescope path; a no-op on a shared desktop / attach-to-existing session. - C ABI: `punktfunk_connect_ex4` adds `launch_id` (NULL = none); `_ex3` now delegates to it. Threaded through NativeClient::connect → WorkerArgs → Hello. - client-rs gains `--launch ID` (headless testing); client-linux passes None (no picker yet). Header regenerated. Next: the Apple library grid passes the picked id via punktfunk_connect_ex4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -353,6 +353,35 @@ pub fn delete_custom(id: &str) -> Result<bool> {
|
||||
// Unified library
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// Resolve a store-qualified library id (as sent by a client in `Hello::launch`) to the shell
|
||||
/// command the host should run for it — looked up in the host's OWN library so a client can only
|
||||
/// pick an existing title, never inject a command. `None` = unknown id, no launch recipe, or a
|
||||
/// malformed Steam appid.
|
||||
///
|
||||
/// - `steam_appid` → `steam steam://rungameid/<appid>` (appid validated as digits, so the only
|
||||
/// client-controlled part of the command is a number).
|
||||
/// - `command` → the stored command verbatim. This string comes from the host's own custom store
|
||||
/// (added by the host operator via the admin UI), never from the client, so it is trusted.
|
||||
pub fn launch_command(id: &str) -> Option<String> {
|
||||
let spec = all_games().into_iter().find(|g| g.id == id)?.launch?;
|
||||
command_for(&spec)
|
||||
}
|
||||
|
||||
/// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of
|
||||
/// [`launch_command`], split out so the appid-validation can be tested without a Steam install).
|
||||
fn command_for(spec: &LaunchSpec) -> Option<String> {
|
||||
match spec.kind.as_str() {
|
||||
"steam_appid" => {
|
||||
// Only digits — the appid is the sole client-influenced part of the command.
|
||||
(!spec.value.is_empty() && spec.value.bytes().all(|b| b.is_ascii_digit()))
|
||||
.then(|| format!("steam steam://rungameid/{}", spec.value))
|
||||
}
|
||||
// Trusted: the command comes from the host's own custom store, never the client.
|
||||
"command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The full library: every store's titles merged + the custom entries, sorted by title.
|
||||
pub fn all_games() -> Vec<GameEntry> {
|
||||
let mut games = SteamProvider.list();
|
||||
@@ -420,6 +449,45 @@ mod tests {
|
||||
assert!(art.header.unwrap().ends_with("/570/header.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_command_resolves_and_guards() {
|
||||
let steam = LaunchSpec {
|
||||
kind: "steam_appid".into(),
|
||||
value: "570".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
command_for(&steam).as_deref(),
|
||||
Some("steam steam://rungameid/570")
|
||||
);
|
||||
// A non-numeric "appid" (e.g. a client trying to inject) is rejected, never interpolated.
|
||||
let evil = LaunchSpec {
|
||||
kind: "steam_appid".into(),
|
||||
value: "570; rm -rf ~".into(),
|
||||
};
|
||||
assert_eq!(command_for(&evil), None);
|
||||
// Custom commands (from the host's own store) pass through verbatim.
|
||||
let custom = LaunchSpec {
|
||||
kind: "command".into(),
|
||||
value: "dolphin-emu --batch".into(),
|
||||
};
|
||||
assert_eq!(command_for(&custom).as_deref(), Some("dolphin-emu --batch"));
|
||||
// Empty / unknown kinds → no command.
|
||||
assert_eq!(
|
||||
command_for(&LaunchSpec {
|
||||
kind: "command".into(),
|
||||
value: " ".into()
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
command_for(&LaunchSpec {
|
||||
kind: "wat".into(),
|
||||
value: "x".into()
|
||||
}),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_entry_maps_to_game_entry() {
|
||||
let g: GameEntry = CustomEntry {
|
||||
|
||||
@@ -520,6 +520,24 @@ async fn serve_session(
|
||||
M3Source::Synthetic => None,
|
||||
};
|
||||
|
||||
// Resolve a requested library launch (the client sends only the store-qualified id;
|
||||
// we look it up in OUR library so a client can't inject a command). Set the gamescope
|
||||
// backend's app env var, exactly as the GameStream /launch path does — safe per-session
|
||||
// (one session at a time). Only the bare-spawn gamescope path reads it; on a shared
|
||||
// desktop (kwin/mutter/wlroots) or an attach-to-existing session it's a harmless no-op.
|
||||
if let Some(id) = hello.launch.as_deref() {
|
||||
match crate::library::launch_command(id) {
|
||||
Some(cmd) => {
|
||||
tracing::info!(launch_id = id, command = %cmd, "launching library title");
|
||||
std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", &cmd);
|
||||
}
|
||||
None => tracing::warn!(
|
||||
launch_id = id,
|
||||
"client requested a launch id not in this host's library — ignoring"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the client's gamepad-backend preference (pure env/cfg check — no probing
|
||||
// needed; the actual pads are created lazily by the input thread).
|
||||
let gamepad = resolve_gamepad(hello.gamepad);
|
||||
@@ -2389,6 +2407,7 @@ mod tests {
|
||||
CompositorPref::Auto,
|
||||
GamepadPref::Auto,
|
||||
0,
|
||||
None, // launch
|
||||
None,
|
||||
Some((cert.clone(), key.clone())),
|
||||
timeout
|
||||
@@ -2419,6 +2438,7 @@ mod tests {
|
||||
CompositorPref::Auto,
|
||||
GamepadPref::Auto,
|
||||
0,
|
||||
None, // launch
|
||||
None,
|
||||
Some((cert, key)),
|
||||
timeout,
|
||||
@@ -2478,6 +2498,7 @@ mod tests {
|
||||
CompositorPref::Auto,
|
||||
GamepadPref::Auto,
|
||||
0,
|
||||
None, // launch
|
||||
None,
|
||||
None,
|
||||
timeout
|
||||
@@ -2503,6 +2524,7 @@ mod tests {
|
||||
CompositorPref::Auto,
|
||||
GamepadPref::Auto,
|
||||
0,
|
||||
None, // launch
|
||||
Some(host_fp),
|
||||
Some((cert.clone(), key.clone())),
|
||||
timeout,
|
||||
|
||||
Reference in New Issue
Block a user