feat(client): shell hands desktop connects to punktfunk-session (phase 3)

start_session_with routes desktop windowed connects to the spawned
Vulkan session binary (--connect --fp, --launch, --fullscreen from the
stream setting); spawn.rs bridges its stdout contract into the shell —
spinner until {"ready":true}, banner from the error/ended JSON line,
exit 3 + trust_rejected routed to the re-pair PIN ceremony, TOFU pins
the advert fingerprint only once the child proves it on a real connect.
The in-process GTK presenter stays for PUNKTFUNK_LEGACY_PRESENTER=1,
Gaming-Mode/--fullscreen and --browse launches (no second toplevel under
gamescope until phase 4 moves the console UI), the request-access flow,
and any spawn failure (silent fallback).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 18:23:36 +02:00
parent a02d0a2e9f
commit a3d3d4738c
4 changed files with 232 additions and 1 deletions
+4 -1
View File
@@ -79,7 +79,10 @@ src/
ui_settings.rs resolution · refresh · decoder · bitrate · compositor · mic
ui_stream.rs the stream window (GtkGraphicsOffload present) + input capture
launch.rs session launch/UI glue over the shared session pump
video_gl.rs VAAPI dmabuf → RGBA GL presenter (EGL import, CICP-driven CSC)
spawn.rs desktop connects → the punktfunk-session Vulkan binary
(PUNKTFUNK_LEGACY_PRESENTER=1 keeps them in-process)
video_gl.rs VAAPI dmabuf → RGBA GL presenter (EGL import, CICP-driven CSC;
the legacy/fallback presenter)
tools/screenshots.sh store screenshot capture (app self-capture; Xvfb fallback)
```
+19
View File
@@ -104,6 +104,25 @@ pub fn start_session_with(
if app.busy.replace(true) {
return;
}
// Phase 3 (linux-client-rearchitecture.md): desktop windowed connects run in the
// punktfunk-session Vulkan binary; this in-process GTK presenter remains the legacy
// path for the cases spawn.rs documents. A failed spawn falls through here too.
let child_fp = pin.map(|p| trust::hex(&p)).or_else(|| req.fp_hex.clone());
let legacy = std::env::var_os("PUNKTFUNK_LEGACY_PRESENTER").is_some_and(|v| v != "0")
|| app.fullscreen
|| app.browse_ui().is_some()
|| opts.waiting.is_some()
|| opts.persist_paired;
if !legacy {
if let Some(fp_hex) = child_fp {
if crate::spawn::spawn_session(app.clone(), req.clone(), fp_hex, pin.is_none())
.is_ok()
{
return; // the child owns the session; busy releases on its exit
}
tracing::warn!("falling back to the in-process presenter");
}
}
let mode = resolve_mode(&app);
let s = app.settings.borrow();
// The presenter raises this when hardware frames can't be displayed; the session pump
+2
View File
@@ -18,6 +18,8 @@ mod cli;
#[cfg(target_os = "linux")]
mod launch;
#[cfg(target_os = "linux")]
mod spawn;
#[cfg(target_os = "linux")]
mod ui_gamepad_library;
#[cfg(target_os = "linux")]
mod ui_hosts;
+207
View File
@@ -0,0 +1,207 @@
//! The shell↔session handoff (punktfunk-planning `linux-client-rearchitecture.md`,
//! Phase 3): desktop connects spawn the `punktfunk-session` Vulkan binary instead of
//! streaming in-process, and this module bridges its stdout contract back into the
//! shell's UI — spinner until `{"ready":true}`, banner from the `{"error"|"ended": …}`
//! JSON line, exit code 3 routed to the re-pair PIN ceremony.
//!
//! The in-process GTK presenter stays for: `PUNKTFUNK_LEGACY_PRESENTER=1`, Gaming-Mode /
//! `--fullscreen` launches and `--browse` (a second toplevel under gamescope is a focus
//! fight — the console path moves wholesale into the session binary in Phase 4), the
//! request-access flow (its "waiting for approval" dialog drives a parked in-process
//! connect), and connects with no fingerprint in hand (the session binary never TOFUs).
use crate::app::App;
use crate::trust;
use crate::ui_hosts::ConnectRequest;
use gtk::glib;
use std::io::BufRead as _;
use std::process::{Command, Stdio};
use std::rc::Rc;
/// One parsed stdout line / lifecycle event from the session child.
enum ChildEvent {
/// `{"ready":true}` — first frame presented; the connect spinner can stop.
Ready,
/// `{"error": msg, "trust_rejected": bool}` — connect failed (the exit follows).
Error { msg: String, trust_rejected: bool },
/// `{"ended": msg}` — the session ran and ended abnormally (host ended, transport…).
Ended(String),
/// The child exited (code; -1 = killed by signal). Always the final event.
Exited(i32),
}
/// Parse one stdout line of the session contract; `None` for anything else (stats…).
fn parse_line(line: &str) -> Option<ChildEvent> {
let v: serde_json::Value = serde_json::from_str(line).ok()?;
if v.get("ready").and_then(|r| r.as_bool()) == Some(true) {
return Some(ChildEvent::Ready);
}
if let Some(msg) = v.get("error").and_then(|m| m.as_str()) {
return Some(ChildEvent::Error {
msg: msg.to_string(),
trust_rejected: v.get("trust_rejected").and_then(|t| t.as_bool()) == Some(true),
});
}
if let Some(msg) = v.get("ended").and_then(|m| m.as_str()) {
return Some(ChildEvent::Ended(msg.to_string()));
}
None
}
/// The session binary: installed next to the shell, else `$PATH` (dev runs from
/// `target/…` land on the sibling).
fn session_binary() -> std::path::PathBuf {
if let Ok(exe) = std::env::current_exe() {
let sibling = exe.with_file_name("punktfunk-session");
if sibling.exists() {
return sibling;
}
}
"punktfunk-session".into()
}
/// Spawn the session binary for a connect with `fp_hex` pinned and drive its lifecycle
/// into the UI. `tofu` = the fingerprint came from the host's advert rather than the
/// store — persist it once the child reports ready (the child connects pinned to it, so
/// ready proves the host really holds that identity; stricter than the in-process TOFU,
/// which pins whatever it observes).
///
/// The caller has already taken `busy`; it is released when the child exits. `Err` =
/// the spawn itself failed (binary missing?) — the caller falls back to the in-process
/// presenter and `busy` stays with it.
pub fn spawn_session(
app: Rc<App>,
req: ConnectRequest,
fp_hex: String,
tofu: bool,
) -> Result<(), ()> {
let mut cmd = Command::new(session_binary());
cmd.arg("--connect")
.arg(format!("{}:{}", req.addr, req.port))
.arg("--fp")
.arg(&fp_hex)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit()); // session logs interleave with the shell's
if let Some((id, _)) = &req.launch {
cmd.arg("--launch").arg(id);
}
if app.settings.borrow().fullscreen_on_stream {
cmd.arg("--fullscreen");
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, binary = %session_binary().display(),
"punktfunk-session spawn failed");
return Err(());
}
};
tracing::info!(host = %req.addr, port = req.port, "session binary spawned");
if let Some(h) = app.hosts_ui() {
h.clear_error();
h.set_connecting(Some(req.card_key()));
}
// Reader thread: stdout lines → events, then reap the child and send the final exit.
let (tx, rx) = async_channel::unbounded::<ChildEvent>();
let stdout = child.stdout.take().expect("piped stdout");
std::thread::Builder::new()
.name("punktfunk-session-io".into())
.spawn(move || {
for line in std::io::BufReader::new(stdout).lines() {
let Ok(line) = line else { break };
if let Some(ev) = parse_line(&line) {
let _ = tx.send_blocking(ev);
}
}
// EOF — the child is exiting (or crashed); reap it so nothing zombies.
let code = child
.wait()
.map(|s| s.code().unwrap_or(-1))
.unwrap_or(-1);
let _ = tx.send_blocking(ChildEvent::Exited(code));
})
.map_err(|e| {
tracing::warn!(error = %e, "session reader thread failed to start");
})?;
// Main-loop side: spinner/banner/re-pair per event; busy releases on exit.
glib::spawn_future_local(async move {
let mut error: Option<(String, bool)> = None;
let mut ended: Option<String> = None;
while let Ok(ev) = rx.recv().await {
match ev {
ChildEvent::Ready => {
if let Some(h) = app.hosts_ui() {
h.set_connecting(None);
}
if tofu {
// The advertised fingerprint just proved itself on a real
// connect — pin it (unpaired), like the in-process TOFU.
trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, false);
app.toast(&format!(
"Trusted on first use — fingerprint {}",
&fp_hex[..16]
));
}
}
ChildEvent::Error { msg, trust_rejected } => error = Some((msg, trust_rejected)),
ChildEvent::Ended(msg) => ended = Some(msg),
ChildEvent::Exited(code) => {
tracing::info!(code, "session binary exited");
app.busy.set(false);
if let Some(h) = app.hosts_ui() {
h.set_connecting(None);
}
match (code, error.take(), ended.take()) {
(0, _, None) => {} // clean end — back on the hosts page, no noise
(0, _, Some(reason)) => app.connect_error(&reason),
(_, Some((_, true)), _) if !tofu => {
// The stored pin no longer matches (rotated cert or
// impostor) — route to re-pairing, like the in-process path.
app.toast("Host fingerprint changed — re-pair with a PIN to continue");
crate::ui_trust::pin_dialog(app.clone(), req.clone());
}
(_, Some((msg, _)), _) => app.connect_error(&format!("Couldn't connect — {msg}")),
(code, None, _) => app.connect_error(&format!(
"Stream session failed (punktfunk-session exit {code})"
)),
}
break;
}
}
}
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_stdout_contract() {
assert!(matches!(parse_line("{\"ready\":true}"), Some(ChildEvent::Ready)));
match parse_line("{\"error\":\"no route\",\"trust_rejected\":false}") {
Some(ChildEvent::Error { msg, trust_rejected }) => {
assert_eq!(msg, "no route");
assert!(!trust_rejected);
}
_ => panic!("error line"),
}
match parse_line("{\"error\":\"pin\",\"trust_rejected\":true}") {
Some(ChildEvent::Error { trust_rejected, .. }) => assert!(trust_rejected),
_ => panic!("trust line"),
}
match parse_line("{\"ended\":\"Host ended the session\"}") {
Some(ChildEvent::Ended(m)) => assert_eq!(m, "Host ended the session"),
_ => panic!("ended line"),
}
// Stats and stray output never become events.
assert!(parse_line("stats: 1280×800@60 · 60 fps").is_none());
assert!(parse_line("").is_none());
assert!(parse_line("{\"other\":1}").is_none());
}
}