refactor(client): relm4 desktop shell; delete legacy presenter + coverflow (phase 5)

The GTK client becomes a relm4 component tree: AppModel owns the window,
trust gate (rules 1-3), and the spawned session child's lifecycle as
typed messages; the hosts page is a child component with a
FactoryVecDeque of host cards — the HostsCallbacks Rc<dyn Fn> bag, the
busy Cell, and the Rc<RefCell<HostsUi>> cross-page pokes are gone.
Trust/settings/library dialogs stay plain GTK, invoked from update with
a ComponentSender.

Deleted with the in-process presenter: ui_stream.rs, video_gl.rs,
ui_gamepad_library.rs, launch.rs, the khronos-egl dep, and the
PUNKTFUNK_LEGACY_PRESENTER hatch. Every stream (and the console library)
now runs in punktfunk-session; the shell spawns it for card connects and
exec's it for --connect/--browse so the Decky wrapper keeps working. The
request-access flow gains --connect-timeout + a CancelHandle that kills
the child. Screenshot scenes re-pointed onto the components (verified:
hosts + library self-capture; the dialog scenes present correctly and
capture under a GL renderer); the gamepad-library scene is gone with the
page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 20:30:53 +02:00
parent be09f9f345
commit cbcd7a5c40
16 changed files with 1680 additions and 4891 deletions
+97 -107
View File
@@ -1,33 +1,48 @@
//! 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).
//! 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::App;
use crate::trust;
use crate::app::AppMsg;
use crate::ui_hosts::ConnectRequest;
use gtk::glib;
use std::io::BufRead as _;
use std::process::{Command, Stdio};
use std::rc::Rc;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
/// One parsed stdout line / lifecycle event from the session child.
/// 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":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…).
@@ -50,7 +65,7 @@ fn parse_line(line: &str) -> Option<ChildEvent> {
/// The session binary: installed next to the shell, else `$PATH` (dev runs from
/// `target/…` land on the sibling).
fn session_binary() -> std::path::PathBuf {
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() {
@@ -60,21 +75,21 @@ fn session_binary() -> std::path::PathBuf {
"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).
/// 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`; 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.
/// 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(
app: Rc<App>,
sender: relm4::Sender<AppMsg>,
req: ConnectRequest,
fp_hex: String,
tofu: bool,
) -> Result<(), ()> {
fullscreen_on_stream: bool,
opts: SpawnOpts,
) -> Result<(), String> {
let mut cmd = Command::new(session_binary());
cmd.arg("--connect")
.arg(format!("{}:{}", req.addr, req.port))
@@ -86,94 +101,69 @@ pub fn spawn_session(
if let Some((id, _)) = &req.launch {
cmd.arg("--launch").arg(id);
}
if app.settings.borrow().fullscreen_on_stream {
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 = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, binary = %session_binary().display(),
"punktfunk-session spawn failed");
return Err(());
}
};
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");
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");
// 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 };
if let Some(ev) = parse_line(&line) {
let _ = tx.send_blocking(ev);
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 — the child is exiting (or crashed); reap it so nothing zombies.
let code = child
.wait()
.map(|s| s.code().unwrap_or(-1))
// 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);
let _ = tx.send_blocking(ChildEvent::Exited(code));
tracing::info!(code, "session binary exited");
let _ = sender.send(AppMsg::SessionExited {
req,
code,
error,
ended,
tofu,
});
})
.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;
}
}
}
});
.map_err(|e| format!("session reader thread: {e}"))?;
Ok(())
}