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:
@@ -286,6 +286,7 @@ fn speed_test(app: Rc<App>, req: ConnectRequest) {
|
|||||||
CompositorPref::Auto,
|
CompositorPref::Auto,
|
||||||
GamepadPref::Auto,
|
GamepadPref::Auto,
|
||||||
0,
|
0,
|
||||||
|
None, // launch: speed-test probe connect, no game
|
||||||
pin,
|
pin,
|
||||||
Some(identity),
|
Some(identity),
|
||||||
std::time::Duration::from_secs(15),
|
std::time::Duration::from_secs(15),
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ fn pump(
|
|||||||
params.compositor,
|
params.compositor,
|
||||||
params.gamepad,
|
params.gamepad,
|
||||||
params.bitrate_kbps,
|
params.bitrate_kbps,
|
||||||
|
None, // launch: the Linux client has no library picker yet
|
||||||
params.pin,
|
params.pin,
|
||||||
Some(params.identity),
|
Some(params.identity),
|
||||||
Duration::from_secs(15),
|
Duration::from_secs(15),
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ struct Args {
|
|||||||
gamepad: GamepadPref,
|
gamepad: GamepadPref,
|
||||||
/// `--bitrate KBPS` — request this encoder bitrate (kilobits/s); 0 = host default.
|
/// `--bitrate KBPS` — request this encoder bitrate (kilobits/s); 0 = host default.
|
||||||
bitrate_kbps: u32,
|
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
|
/// `--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.
|
/// bandwidth probe burst at `KBPS`, then report measured throughput + loss.
|
||||||
speed_test: Option<(u32, u32)>,
|
speed_test: Option<(u32, u32)>,
|
||||||
@@ -194,6 +197,7 @@ fn parse_args() -> Args {
|
|||||||
compositor,
|
compositor,
|
||||||
gamepad,
|
gamepad,
|
||||||
bitrate_kbps: get("--bitrate").and_then(|s| s.parse().ok()).unwrap_or(0),
|
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| {
|
speed_test: get("--speed-test").and_then(|s| {
|
||||||
let (kbps, ms) = s.split_once(':')?;
|
let (kbps, ms) = s.split_once(':')?;
|
||||||
Some((kbps.parse().ok()?, ms.parse().ok()?))
|
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
|
// `--name` (also the pairing label) — shown in the host's pending-approval list when
|
||||||
// this client knocks on a pairing-required host.
|
// this client knocks on a pairing-required host.
|
||||||
name: Some(args.name.clone()),
|
name: Some(args.name.clone()),
|
||||||
|
// `--launch ID` — host resolves it against its own library and runs it this session.
|
||||||
|
launch: args.launch.clone(),
|
||||||
}
|
}
|
||||||
.encode(),
|
.encode(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -796,6 +796,53 @@ pub unsafe extern "C" fn punktfunk_connect_ex3(
|
|||||||
client_cert_pem: *const std::os::raw::c_char,
|
client_cert_pem: *const std::os::raw::c_char,
|
||||||
client_key_pem: *const std::os::raw::c_char,
|
client_key_pem: *const std::os::raw::c_char,
|
||||||
timeout_ms: u32,
|
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 {
|
) -> *mut PunktfunkConnection {
|
||||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||||
if host.is_null() {
|
if host.is_null() {
|
||||||
@@ -805,6 +852,11 @@ pub unsafe extern "C" fn punktfunk_connect_ex3(
|
|||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(_) => return std::ptr::null_mut(),
|
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 {
|
let mode = crate::config::Mode {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@@ -839,6 +891,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex3(
|
|||||||
pref,
|
pref,
|
||||||
gamepad,
|
gamepad,
|
||||||
bitrate_kbps,
|
bitrate_kbps,
|
||||||
|
launch,
|
||||||
pin,
|
pin,
|
||||||
identity,
|
identity,
|
||||||
std::time::Duration::from_millis(timeout_ms as u64),
|
std::time::Duration::from_millis(timeout_ms as u64),
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ impl NativeClient {
|
|||||||
compositor: CompositorPref,
|
compositor: CompositorPref,
|
||||||
gamepad: GamepadPref,
|
gamepad: GamepadPref,
|
||||||
bitrate_kbps: u32,
|
bitrate_kbps: u32,
|
||||||
|
launch: Option<String>,
|
||||||
pin: Option<[u8; 32]>,
|
pin: Option<[u8; 32]>,
|
||||||
identity: Option<(String, String)>,
|
identity: Option<(String, String)>,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
@@ -214,6 +215,7 @@ impl NativeClient {
|
|||||||
compositor,
|
compositor,
|
||||||
gamepad,
|
gamepad,
|
||||||
bitrate_kbps,
|
bitrate_kbps,
|
||||||
|
launch,
|
||||||
pin,
|
pin,
|
||||||
identity,
|
identity,
|
||||||
frame_tx,
|
frame_tx,
|
||||||
@@ -526,6 +528,7 @@ struct WorkerArgs {
|
|||||||
compositor: CompositorPref,
|
compositor: CompositorPref,
|
||||||
gamepad: GamepadPref,
|
gamepad: GamepadPref,
|
||||||
bitrate_kbps: u32,
|
bitrate_kbps: u32,
|
||||||
|
launch: Option<String>,
|
||||||
pin: Option<[u8; 32]>,
|
pin: Option<[u8; 32]>,
|
||||||
identity: Option<(String, String)>,
|
identity: Option<(String, String)>,
|
||||||
frame_tx: SyncSender<Frame>,
|
frame_tx: SyncSender<Frame>,
|
||||||
@@ -552,6 +555,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
compositor,
|
compositor,
|
||||||
gamepad,
|
gamepad,
|
||||||
bitrate_kbps,
|
bitrate_kbps,
|
||||||
|
launch,
|
||||||
pin,
|
pin,
|
||||||
identity,
|
identity,
|
||||||
frame_tx,
|
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
|
// 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.
|
// host falls back to a fingerprint-derived label in its pending-approval list.
|
||||||
name: None,
|
name: None,
|
||||||
|
// Library id to launch this session, if the embedder asked for one.
|
||||||
|
launch: launch.clone(),
|
||||||
}
|
}
|
||||||
.encode(),
|
.encode(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,12 +62,24 @@ pub struct Hello {
|
|||||||
/// Appended to the wire form as `len u8 || UTF-8` (≤ [`HELLO_NAME_MAX`] bytes) — omitted by
|
/// 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).
|
/// older clients (decodes to `None`; the host falls back to a fingerprint-derived label).
|
||||||
pub name: Option<String>,
|
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
|
/// 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).
|
/// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
|
||||||
pub const HELLO_NAME_MAX: usize = 64;
|
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.
|
/// `host → client`: the complete session offer.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub struct Welcome {
|
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 {
|
impl Hello {
|
||||||
pub fn encode(&self) -> Vec<u8> {
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
let mut b = Vec::with_capacity(22);
|
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.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.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
|
b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 22..26
|
||||||
if let Some(name) = &self.name {
|
// name at offset 26: len u8 || UTF-8. Omitted when `None` *and* there is no later field —
|
||||||
// Appended at offset 26: len u8 || UTF-8. This is the LAST trailing field — `None`
|
// so a Hello with neither name nor launch stays byte-identical to the bitrate-era form
|
||||||
// emits nothing (so a no-name Hello is byte-identical to the bitrate-era form), which
|
// (26 bytes). When `launch` is present we must still emit name's length byte (0 for None)
|
||||||
// means a *future* field can't simply follow `name` at a fixed offset; it would need
|
// so `launch` lands at a deterministic offset.
|
||||||
// its own presence flag. Truncate to a char boundary within HELLO_NAME_MAX.
|
match (&self.name, &self.launch) {
|
||||||
let mut n = name.as_str();
|
(None, None) => {}
|
||||||
while n.len() > HELLO_NAME_MAX {
|
(name, _) => {
|
||||||
let mut cut = HELLO_NAME_MAX;
|
let n = truncate_to(name.as_deref().unwrap_or(""), HELLO_NAME_MAX);
|
||||||
while !n.is_char_boundary(cut) {
|
b.push(n.len() as u8);
|
||||||
cut -= 1;
|
b.extend_from_slice(n.as_bytes());
|
||||||
}
|
|
||||||
n = &n[..cut];
|
|
||||||
}
|
}
|
||||||
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
|
b
|
||||||
}
|
}
|
||||||
@@ -540,6 +567,19 @@ impl Hello {
|
|||||||
.and_then(|s| std::str::from_utf8(s).ok())
|
.and_then(|s| std::str::from_utf8(s).ok())
|
||||||
.map(String::from)
|
.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,
|
gamepad: GamepadPref::DualSense,
|
||||||
bitrate_kbps: 25_000,
|
bitrate_kbps: 25_000,
|
||||||
name: Some("Test Device".into()),
|
name: Some("Test Device".into()),
|
||||||
|
launch: Some("steam:570".into()),
|
||||||
};
|
};
|
||||||
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
|
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
|
||||||
let s = Start {
|
let s = Start {
|
||||||
@@ -1560,6 +1601,7 @@ mod tests {
|
|||||||
gamepad: GamepadPref::DualSense,
|
gamepad: GamepadPref::DualSense,
|
||||||
bitrate_kbps: 80_000,
|
bitrate_kbps: 80_000,
|
||||||
name: None,
|
name: None,
|
||||||
|
launch: None,
|
||||||
};
|
};
|
||||||
let enc = h.encode();
|
let enc = h.encode();
|
||||||
assert_eq!(enc.len(), 26);
|
assert_eq!(enc.len(), 26);
|
||||||
@@ -1629,6 +1671,7 @@ mod tests {
|
|||||||
gamepad: GamepadPref::Auto,
|
gamepad: GamepadPref::Auto,
|
||||||
bitrate_kbps: 0,
|
bitrate_kbps: 0,
|
||||||
name: Some("Enrico's MacBook".into()),
|
name: Some("Enrico's MacBook".into()),
|
||||||
|
launch: None,
|
||||||
};
|
};
|
||||||
let enc = base.encode();
|
let enc = base.encode();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1661,6 +1704,60 @@ mod tests {
|
|||||||
assert_eq!(Hello::decode(&bad_utf8).unwrap().name, None);
|
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]
|
#[test]
|
||||||
fn reconfigure_roundtrip() {
|
fn reconfigure_roundtrip() {
|
||||||
let rq = Reconfigure {
|
let rq = Reconfigure {
|
||||||
@@ -1784,6 +1881,7 @@ mod tests {
|
|||||||
gamepad: GamepadPref::Auto,
|
gamepad: GamepadPref::Auto,
|
||||||
bitrate_kbps: 0,
|
bitrate_kbps: 0,
|
||||||
name: None,
|
name: None,
|
||||||
|
launch: None,
|
||||||
}
|
}
|
||||||
.encode();
|
.encode();
|
||||||
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
|
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
|
||||||
|
|||||||
@@ -353,6 +353,35 @@ pub fn delete_custom(id: &str) -> Result<bool> {
|
|||||||
// Unified library
|
// 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.
|
/// The full library: every store's titles merged + the custom entries, sorted by title.
|
||||||
pub fn all_games() -> Vec<GameEntry> {
|
pub fn all_games() -> Vec<GameEntry> {
|
||||||
let mut games = SteamProvider.list();
|
let mut games = SteamProvider.list();
|
||||||
@@ -420,6 +449,45 @@ mod tests {
|
|||||||
assert!(art.header.unwrap().ends_with("/570/header.jpg"));
|
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]
|
#[test]
|
||||||
fn custom_entry_maps_to_game_entry() {
|
fn custom_entry_maps_to_game_entry() {
|
||||||
let g: GameEntry = CustomEntry {
|
let g: GameEntry = CustomEntry {
|
||||||
|
|||||||
@@ -520,6 +520,24 @@ async fn serve_session(
|
|||||||
M3Source::Synthetic => None,
|
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
|
// Resolve the client's gamepad-backend preference (pure env/cfg check — no probing
|
||||||
// needed; the actual pads are created lazily by the input thread).
|
// needed; the actual pads are created lazily by the input thread).
|
||||||
let gamepad = resolve_gamepad(hello.gamepad);
|
let gamepad = resolve_gamepad(hello.gamepad);
|
||||||
@@ -2389,6 +2407,7 @@ mod tests {
|
|||||||
CompositorPref::Auto,
|
CompositorPref::Auto,
|
||||||
GamepadPref::Auto,
|
GamepadPref::Auto,
|
||||||
0,
|
0,
|
||||||
|
None, // launch
|
||||||
None,
|
None,
|
||||||
Some((cert.clone(), key.clone())),
|
Some((cert.clone(), key.clone())),
|
||||||
timeout
|
timeout
|
||||||
@@ -2419,6 +2438,7 @@ mod tests {
|
|||||||
CompositorPref::Auto,
|
CompositorPref::Auto,
|
||||||
GamepadPref::Auto,
|
GamepadPref::Auto,
|
||||||
0,
|
0,
|
||||||
|
None, // launch
|
||||||
None,
|
None,
|
||||||
Some((cert, key)),
|
Some((cert, key)),
|
||||||
timeout,
|
timeout,
|
||||||
@@ -2478,6 +2498,7 @@ mod tests {
|
|||||||
CompositorPref::Auto,
|
CompositorPref::Auto,
|
||||||
GamepadPref::Auto,
|
GamepadPref::Auto,
|
||||||
0,
|
0,
|
||||||
|
None, // launch
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
timeout
|
timeout
|
||||||
@@ -2503,6 +2524,7 @@ mod tests {
|
|||||||
CompositorPref::Auto,
|
CompositorPref::Auto,
|
||||||
GamepadPref::Auto,
|
GamepadPref::Auto,
|
||||||
0,
|
0,
|
||||||
|
None, // launch
|
||||||
Some(host_fp),
|
Some(host_fp),
|
||||||
Some((cert.clone(), key.clone())),
|
Some((cert.clone(), key.clone())),
|
||||||
timeout,
|
timeout,
|
||||||
|
|||||||
@@ -152,6 +152,12 @@
|
|||||||
#define HELLO_NAME_MAX 64
|
#define HELLO_NAME_MAX 64
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// 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.
|
||||||
|
#define HELLO_LAUNCH_MAX 128
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Type byte of [`Reconfigure`] (first byte after the magic).
|
// Type byte of [`Reconfigure`] (first byte after the magic).
|
||||||
#define MSG_RECONFIGURE 1
|
#define MSG_RECONFIGURE 1
|
||||||
@@ -648,6 +654,31 @@ PunktfunkConnection *punktfunk_connect_ex3(const char *host,
|
|||||||
uint32_t timeout_ms);
|
uint32_t timeout_ms);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// 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.
|
||||||
|
PunktfunkConnection *punktfunk_connect_ex4(const char *host,
|
||||||
|
uint16_t port,
|
||||||
|
uint32_t width,
|
||||||
|
uint32_t height,
|
||||||
|
uint32_t refresh_hz,
|
||||||
|
uint32_t compositor,
|
||||||
|
uint32_t gamepad,
|
||||||
|
uint32_t bitrate_kbps,
|
||||||
|
const char *launch_id,
|
||||||
|
const uint8_t *pin_sha256,
|
||||||
|
uint8_t *observed_sha256_out,
|
||||||
|
const char *client_cert_pem,
|
||||||
|
const char *client_key_pem,
|
||||||
|
uint32_t timeout_ms);
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Generate a persistent client identity: a self-signed certificate + private key, both
|
// Generate a persistent client identity: a self-signed certificate + private key, both
|
||||||
// PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both
|
// PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both
|
||||||
|
|||||||
Reference in New Issue
Block a user