062a54e3a5
`cargo fmt --all --check` (ci.yml) was red on main: the client re-architecture
commits and origin's windows-shortcut commit landed with rustfmt violations
(e.g. a 104-char .with_context line in hyprland.rs, an unsorted mod block in
vdisplay.rs, the input.rs `{`-placement CI flagged). Reformat the tree so the
fmt gate passes; no functional changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
204 lines
7.5 KiB
Rust
204 lines
7.5 KiB
Rust
//! 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<u64>,
|
||
/// 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<CancelHandle>,
|
||
}
|
||
|
||
/// 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<Mutex<Option<Child>>>);
|
||
|
||
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<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).
|
||
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<AppMsg>,
|
||
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<String> = 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());
|
||
}
|
||
}
|