//! The shell↔session handoff: every stream runs in the spawned `punktfunk-session` //! Vulkan binary (the legacy in-process presenter is gone — phase 5 of //! punktfunk-planning `linux-client-rearchitecture.md`). This module owns the child's //! lifecycle plumbing — its stdout contract parsed into typed [`AppMsg`]s the relm4 app //! consumes: spinner until `{"ready":true}`, banner from the `{"error"|"ended": …}` //! line, exit code 3 + `trust_rejected` routed to the re-pair PIN ceremony. use crate::app::AppMsg; use crate::ui_hosts::ConnectRequest; use std::io::BufRead as _; use std::process::{Child, Command, Stdio}; use std::sync::{Arc, Mutex}; /// Spawn tunables beyond a plain connect. #[derive(Debug, Default)] pub struct SpawnOpts { /// Handshake budget override (`--connect-timeout`) — the request-access flow passes /// ~185 s because the host PARKS the connection until the operator approves. pub connect_timeout_secs: Option, /// Persist the host as *paired* once the child reports ready (request-access: the /// operator's approval IS the pairing). Plain TOFU persists unpaired. pub persist_paired: bool, /// A cancel handle to arm (request-access's waiting dialog): killing the child is /// the only abort a parked connect has. pub cancel: Option, } /// Kills the spawned session child (the request-access Cancel button). Safe to call /// any time; a child that already exited is a no-op. #[derive(Clone, Debug, Default)] pub struct CancelHandle(Arc>>); impl CancelHandle { pub fn kill(&self) { if let Some(child) = self.0.lock().unwrap().as_mut() { let _ = child.kill(); } } } /// One parsed stdout line from the session child's contract. enum ChildEvent { Ready, Error { msg: String, trust_rejected: bool }, Ended(String), } /// 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). pub 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 translate its /// lifecycle into [`AppMsg`]s. `tofu` = the fingerprint came from the host's advert /// rather than the store — the app persists it once the child reports ready (the child /// connects pinned to it, so ready proves the host really holds that identity). /// /// The caller has already taken `busy`; [`AppMsg::SessionExited`] releases it. `Err` = /// the spawn itself failed (binary missing?) — surfaced as a connect error. pub fn spawn_session( sender: relm4::Sender, req: ConnectRequest, fp_hex: String, tofu: bool, fullscreen_on_stream: bool, opts: SpawnOpts, ) -> Result<(), String> { 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 let Some(secs) = opts.connect_timeout_secs { cmd.arg("--connect-timeout").arg(secs.to_string()); } if fullscreen_on_stream { cmd.arg("--fullscreen"); } let mut child = cmd .spawn() .map_err(|e| format!("couldn't start punktfunk-session: {e}"))?; tracing::info!(host = %req.addr, port = req.port, "session binary spawned"); let stdout = child.stdout.take().expect("piped stdout"); // Park the child where the cancel handle (and the reader, for the final reap) can // reach it. let slot = opts.cancel.clone().unwrap_or_default(); *slot.0.lock().unwrap() = Some(child); let persist_paired = opts.persist_paired; std::thread::Builder::new() .name("punktfunk-session-io".into()) .spawn(move || { let mut error: Option<(String, bool)> = None; let mut ended: Option = None; for line in std::io::BufReader::new(stdout).lines() { let Ok(line) = line else { break }; match parse_line(&line) { Some(ChildEvent::Ready) => { let _ = sender.send(AppMsg::SessionReady { req: req.clone(), fp_hex: fp_hex.clone(), tofu, persist_paired, }); } Some(ChildEvent::Error { msg, trust_rejected, }) => { error = Some((msg, trust_rejected)); } Some(ChildEvent::Ended(msg)) => ended = Some(msg), None => {} } } // EOF — reap the child (killed-by-cancel lands here too; -1 = signal). let code = slot .0 .lock() .unwrap() .take() .and_then(|mut c| c.wait().ok()) .and_then(|s| s.code()) .unwrap_or(-1); tracing::info!(code, "session binary exited"); let _ = sender.send(AppMsg::SessionExited { req, code, error, ended, tofu, }); }) .map_err(|e| format!("session reader thread: {e}"))?; 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()); } }