From a3d3d4738c86c942f00122864efa0fea9ac497b4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 7 Jul 2026 18:23:36 +0200 Subject: [PATCH] feat(client): shell hands desktop connects to punktfunk-session (phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- clients/linux/README.md | 5 +- clients/linux/src/launch.rs | 19 ++++ clients/linux/src/main.rs | 2 + clients/linux/src/spawn.rs | 207 ++++++++++++++++++++++++++++++++++++ 4 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 clients/linux/src/spawn.rs diff --git a/clients/linux/README.md b/clients/linux/README.md index 8d98ac9d..c25b5d43 100644 --- a/clients/linux/README.md +++ b/clients/linux/README.md @@ -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) ``` diff --git a/clients/linux/src/launch.rs b/clients/linux/src/launch.rs index bca38441..7f3429b0 100644 --- a/clients/linux/src/launch.rs +++ b/clients/linux/src/launch.rs @@ -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 diff --git a/clients/linux/src/main.rs b/clients/linux/src/main.rs index aa6adf38..9f4eb110 100644 --- a/clients/linux/src/main.rs +++ b/clients/linux/src/main.rs @@ -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; diff --git a/clients/linux/src/spawn.rs b/clients/linux/src/spawn.rs new file mode 100644 index 00000000..b37a364b --- /dev/null +++ b/clients/linux/src/spawn.rs @@ -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 { + 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, + 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::(); + 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 = 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()); + } +}