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

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:
2026-06-14 14:56:18 +00:00
parent 1b610d6bf5
commit 27e58658af
9 changed files with 300 additions and 14 deletions
+1
View File
@@ -286,6 +286,7 @@ fn speed_test(app: Rc<App>, req: ConnectRequest) {
CompositorPref::Auto,
GamepadPref::Auto,
0,
None, // launch: speed-test probe connect, no game
pin,
Some(identity),
std::time::Duration::from_secs(15),
@@ -90,6 +90,7 @@ fn pump(
params.compositor,
params.gamepad,
params.bitrate_kbps,
None, // launch: the Linux client has no library picker yet
params.pin,
Some(params.identity),
Duration::from_secs(15),
+6
View File
@@ -76,6 +76,9 @@ struct Args {
gamepad: GamepadPref,
/// `--bitrate KBPS` — request this encoder bitrate (kilobits/s); 0 = host default.
bitrate_kbps: u32,
/// `--launch ID` — ask the host to launch a library title in this session (a store-qualified
/// id from the host's `GET /api/v1/library`, e.g. `steam:570`). Host resolves it; `None` = none.
launch: Option<String>,
/// `--speed-test KBPS:MS` — after the stream starts, ask the host for a `MS`-millisecond
/// bandwidth probe burst at `KBPS`, then report measured throughput + loss.
speed_test: Option<(u32, u32)>,
@@ -194,6 +197,7 @@ fn parse_args() -> Args {
compositor,
gamepad,
bitrate_kbps: get("--bitrate").and_then(|s| s.parse().ok()).unwrap_or(0),
launch: get("--launch").map(str::to_string),
speed_test: get("--speed-test").and_then(|s| {
let (kbps, ms) = s.split_once(':')?;
Some((kbps.parse().ok()?, ms.parse().ok()?))
@@ -374,6 +378,8 @@ async fn session(args: Args) -> Result<()> {
// `--name` (also the pairing label) — shown in the host's pending-approval list when
// this client knocks on a pairing-required host.
name: Some(args.name.clone()),
// `--launch ID` — host resolves it against its own library and runs it this session.
launch: args.launch.clone(),
}
.encode(),
)
+53
View File
@@ -796,6 +796,53 @@ pub unsafe extern "C" fn punktfunk_connect_ex3(
client_cert_pem: *const std::os::raw::c_char,
client_key_pem: *const std::os::raw::c_char,
timeout_ms: u32,
) -> *mut PunktfunkConnection {
// Delegate to the launch-aware variant with no game requested (the host's default session).
unsafe {
punktfunk_connect_ex4(
host,
port,
width,
height,
refresh_hz,
compositor,
gamepad,
bitrate_kbps,
std::ptr::null(),
pin_sha256,
observed_sha256_out,
client_cert_pem,
client_key_pem,
timeout_ms,
)
}
}
/// Like [`punktfunk_connect_ex3`], but additionally asks the host to launch a library title in
/// this session. `launch_id` is a store-qualified [`crate::library`-style] id as returned by the
/// host's `GET /api/v1/library` (`steam:<appid>` / `custom:<id>`); the host resolves it against
/// its OWN library and runs the matching recipe — the client never sends a raw command. `NULL`
/// (or an empty / unknown id) ⇒ the host's default session, no game launched.
///
/// # Safety
/// Same as [`punktfunk_connect`]; `launch_id`, when non-NULL, must be a NUL-terminated C string.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connect_ex4(
host: *const std::os::raw::c_char,
port: u16,
width: u32,
height: u32,
refresh_hz: u32,
compositor: u32,
gamepad: u32,
bitrate_kbps: u32,
launch_id: *const std::os::raw::c_char,
pin_sha256: *const u8,
observed_sha256_out: *mut u8,
client_cert_pem: *const std::os::raw::c_char,
client_key_pem: *const std::os::raw::c_char,
timeout_ms: u32,
) -> *mut PunktfunkConnection {
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
if host.is_null() {
@@ -805,6 +852,11 @@ pub unsafe extern "C" fn punktfunk_connect_ex3(
Ok(s) => s,
Err(_) => return std::ptr::null_mut(),
};
// A bad-UTF-8 launch id is non-fatal — treat it as "no game" rather than failing connect.
let launch = match unsafe { opt_cstr(launch_id) } {
Ok(Some(s)) if !s.is_empty() => Some(s.to_string()),
_ => None,
};
let mode = crate::config::Mode {
width,
height,
@@ -839,6 +891,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex3(
pref,
gamepad,
bitrate_kbps,
launch,
pin,
identity,
std::time::Duration::from_millis(timeout_ms as u64),
+6
View File
@@ -172,6 +172,7 @@ impl NativeClient {
compositor: CompositorPref,
gamepad: GamepadPref,
bitrate_kbps: u32,
launch: Option<String>,
pin: Option<[u8; 32]>,
identity: Option<(String, String)>,
timeout: Duration,
@@ -214,6 +215,7 @@ impl NativeClient {
compositor,
gamepad,
bitrate_kbps,
launch,
pin,
identity,
frame_tx,
@@ -526,6 +528,7 @@ struct WorkerArgs {
compositor: CompositorPref,
gamepad: GamepadPref,
bitrate_kbps: u32,
launch: Option<String>,
pin: Option<[u8; 32]>,
identity: Option<(String, String)>,
frame_tx: SyncSender<Frame>,
@@ -552,6 +555,7 @@ async fn worker_main(args: WorkerArgs) {
compositor,
gamepad,
bitrate_kbps,
launch,
pin,
identity,
frame_tx,
@@ -608,6 +612,8 @@ async fn worker_main(args: WorkerArgs) {
// No device name yet: the connect ABI has no name parameter (pairing does). The
// host falls back to a fingerprint-derived label in its pending-approval list.
name: None,
// Library id to launch this session, if the embedder asked for one.
launch: launch.clone(),
}
.encode(),
)
+112 -14
View File
@@ -62,12 +62,24 @@ pub struct Hello {
/// Appended to the wire form as `len u8 || UTF-8` (≤ [`HELLO_NAME_MAX`] bytes) — omitted by
/// older clients (decodes to `None`; the host falls back to a fingerprint-derived label).
pub name: Option<String>,
/// Library entry the client wants this session to launch (the store-qualified `GameEntry.id`,
/// e.g. `steam:570` / `custom:abc123`). The host resolves it against ITS OWN library and runs
/// the matching launch recipe in the session — the client never sends a raw command, so a
/// remote peer can't inject one. `None` = no game requested (the host's default session).
/// Appended after `name` as `len u8 || UTF-8` (≤ [`HELLO_LAUNCH_MAX`] bytes); when present but
/// `name` is absent, a zero-length name placeholder precedes it so the offset stays
/// deterministic. Omitted by older clients (decodes to `None`).
pub launch: Option<String>,
}
/// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on
/// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
pub const HELLO_NAME_MAX: usize = 64;
/// Longest library id carried in a [`Hello::launch`] (bytes of UTF-8). Ids are short
/// (`steam:<appid>` / `custom:<12 hex>`); the cap just bounds an attacker-controlled field.
pub const HELLO_LAUNCH_MAX: usize = 128;
/// `host → client`: the complete session offer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Welcome {
@@ -473,6 +485,19 @@ pub mod pake {
}
}
/// Truncate `s` to at most `max` bytes on a UTF-8 char boundary (so a multi-byte char straddling
/// the cap is dropped whole, never split). Shared by Hello's length-prefixed name/launch fields.
fn truncate_to(s: &str, max: usize) -> &str {
if s.len() <= max {
return s;
}
let mut cut = max;
while !s.is_char_boundary(cut) {
cut -= 1;
}
&s[..cut]
}
impl Hello {
pub fn encode(&self) -> Vec<u8> {
let mut b = Vec::with_capacity(22);
@@ -484,21 +509,23 @@ impl Hello {
b.push(self.compositor.to_u8()); // appended at offset 20 — older hosts read [0..20] and skip it
b.push(self.gamepad.to_u8()); // appended at offset 21 — same back-compat discipline
b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 22..26
if let Some(name) = &self.name {
// Appended at offset 26: len u8 || UTF-8. This is the LAST trailing field — `None`
// emits nothing (so a no-name Hello is byte-identical to the bitrate-era form), which
// means a *future* field can't simply follow `name` at a fixed offset; it would need
// its own presence flag. Truncate to a char boundary within HELLO_NAME_MAX.
let mut n = name.as_str();
while n.len() > HELLO_NAME_MAX {
let mut cut = HELLO_NAME_MAX;
while !n.is_char_boundary(cut) {
cut -= 1;
}
n = &n[..cut];
// name at offset 26: len u8 || UTF-8. Omitted when `None` *and* there is no later field —
// so a Hello with neither name nor launch stays byte-identical to the bitrate-era form
// (26 bytes). When `launch` is present we must still emit name's length byte (0 for None)
// so `launch` lands at a deterministic offset.
match (&self.name, &self.launch) {
(None, None) => {}
(name, _) => {
let n = truncate_to(name.as_deref().unwrap_or(""), HELLO_NAME_MAX);
b.push(n.len() as u8);
b.extend_from_slice(n.as_bytes());
}
b.push(n.len() as u8);
b.extend_from_slice(n.as_bytes());
}
// launch after name: len u8 || UTF-8. Last trailing field.
if let Some(launch) = &self.launch {
let l = truncate_to(launch, HELLO_LAUNCH_MAX);
b.push(l.len() as u8);
b.extend_from_slice(l.as_bytes());
}
b
}
@@ -540,6 +567,19 @@ impl Hello {
.and_then(|s| std::str::from_utf8(s).ok())
.map(String::from)
}),
// Optional trailing launch id, positioned right after name's `len u8 || UTF-8` block.
// The raw name-length byte (even when oversized/zero) determines where launch starts,
// so a corrupt name never panics — it just pushes the launch offset out of range → None.
launch: b.get(26).and_then(|&name_len| {
let off = 27 + name_len as usize; // start of launch's length byte
let len = *b.get(off)? as usize;
if len == 0 || len > HELLO_LAUNCH_MAX {
return None;
}
b.get(off + 1..off + 1 + len)
.and_then(|s| std::str::from_utf8(s).ok())
.map(String::from)
}),
})
}
}
@@ -1495,6 +1535,7 @@ mod tests {
gamepad: GamepadPref::DualSense,
bitrate_kbps: 25_000,
name: Some("Test Device".into()),
launch: Some("steam:570".into()),
};
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
let s = Start {
@@ -1560,6 +1601,7 @@ mod tests {
gamepad: GamepadPref::DualSense,
bitrate_kbps: 80_000,
name: None,
launch: None,
};
let enc = h.encode();
assert_eq!(enc.len(), 26);
@@ -1629,6 +1671,7 @@ mod tests {
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
name: Some("Enrico's MacBook".into()),
launch: None,
};
let enc = base.encode();
assert_eq!(
@@ -1661,6 +1704,60 @@ mod tests {
assert_eq!(Hello::decode(&bad_utf8).unwrap().name, None);
}
#[test]
fn hello_launch_roundtrip_and_back_compat() {
let base = Hello {
abi_version: 2,
mode: Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
},
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
name: None,
launch: None,
};
// launch alone (no name): a zero-length name placeholder keeps the offset deterministic.
let with_launch = Hello {
launch: Some("steam:570".into()),
..base.clone()
};
assert_eq!(Hello::decode(&with_launch.encode()).unwrap(), with_launch);
// launch + name together.
let both = Hello {
name: Some("Enrico's Mac".into()),
launch: Some("custom:abc123".into()),
..base.clone()
};
assert_eq!(Hello::decode(&both.encode()).unwrap(), both);
// name but no launch (a name-era client): launch decodes None.
let name_only = Hello {
name: Some("Enrico's Mac".into()),
..base.clone()
};
assert_eq!(Hello::decode(&name_only.encode()).unwrap().launch, None);
// Neither field → still the 26-byte bitrate-era form (no launch placeholder emitted).
assert_eq!(base.encode().len(), 26);
assert_eq!(Hello::decode(&base.encode()).unwrap().launch, None);
// A bitrate-era (26-byte) peer reading a launch-bearing Hello ignores it.
assert_eq!(
Hello::decode(&with_launch.encode()[..26]).unwrap().launch,
None
);
// Over-long ids truncate on a char boundary within HELLO_LAUNCH_MAX.
let long = Hello {
launch: Some(format!("{}ü", "x".repeat(HELLO_LAUNCH_MAX - 1))),
..base.clone()
};
let dec = Hello::decode(&long.encode())
.unwrap()
.launch
.expect("present");
assert!(dec.len() <= HELLO_LAUNCH_MAX && dec.starts_with('x'));
}
#[test]
fn reconfigure_roundtrip() {
let rq = Reconfigure {
@@ -1784,6 +1881,7 @@ mod tests {
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
name: None,
launch: None,
}
.encode();
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
+68
View File
@@ -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 {
+22
View File
@@ -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,