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:
+97
-107
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user