f4850625bd
apple / swift (push) Successful in 1m10s
android / android (push) Successful in 4m56s
arch / build-publish (push) Successful in 5m56s
audit / bun-audit (push) Successful in 13s
audit / cargo-audit (push) Successful in 1m34s
ci / web (push) Successful in 1m11s
windows-host / package (push) Successful in 7m57s
ci / docs-site (push) Successful in 1m27s
release / apple (push) Successful in 8m39s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m26s
ci / bench (push) Successful in 5m0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m16s
apple / screenshots (push) Successful in 5m35s
ci / rust (push) Successful in 10m32s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m47s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m36s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 6m38s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m39s
flatpak / build-publish (push) Successful in 5m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m33s
docker / deploy-docs (push) Successful in 18s
UX polish batch 2 (plan workstreams D/E5/E6): - D: a mouse/keyboard game library page over pf-client-core::library (the GTK ui_library.rs counterpart): responsive 2-6 column poster grid (2:3 portraits, store badge, monogram placeholder while art streams in), loading / error+retry / empty / grid states, tap-to-play via a normal spawn carrying --launch id. Poster bytes land in a disk cache (%LOCALAPPDATA%\punktfunk\art-cache) so the Image widget loads file:/// URIs (reactor has no from-bytes source) and revisits render instantly. Reached from a paired host's "Browse library..." menu item behind the new "Show game library (experimental)" Settings toggle (the shared library_enabled field, Apple/GTK parity). A Shared::library_gen generation guard keeps a superseded fetch from publishing (speed-test pattern). - E5: the session window opens at the shell's own top-left (--window-pos, new SessionOpts::window_pos) instead of centered on the primary display - streams land on the monitor the shell is on, and the hide/restore handoff reads as one window changing content. Fullscreen follows that display. - E6: the Help shortcuts add Alt+Enter and the controller escape chord (LB+RB+Start+Back, hold to disconnect). Also: rustfmt normalization of the merged probe helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
253 lines
9.7 KiB
Rust
253 lines
9.7 KiB
Rust
//! The shell↔session handoff: streams run in the spawned `punktfunk-session` Vulkan
|
|
//! binary (session-always, mirroring the GTK shell's `clients/linux/src/spawn.rs`). This
|
|
//! module owns the child's lifecycle plumbing — spawned with CREATE_NO_WINDOW (the
|
|
//! session keeps the console subsystem for its stdout contract; without the flag a GUI
|
|
//! parent would pop a console window), its stdout contract parsed into typed
|
|
//! [`SpawnEvent`]s a reader thread hands to the app's navigation closure: spinner until
|
|
//! `{"ready":true}`, banner from the `{"error"|"ended": …}` line, `trust_rejected`
|
|
//! routed to the re-pair PIN ceremony, `stats:` lines to the session status page.
|
|
//!
|
|
//! The legacy in-process D3D11VA presenter remains reachable via the
|
|
//! `PUNKTFUNK_BUILTIN_STREAM=1` env override (`app::use_builtin_stream`) — the
|
|
//! developer A/B baseline until its deletion.
|
|
|
|
use std::io::BufRead as _;
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
/// One parsed event from the session child.
|
|
pub(crate) enum SpawnEvent {
|
|
/// The child presented its first frame (its window is up and streaming).
|
|
Ready,
|
|
/// One `stats:` line, already human-formatted by the session (per 1 s window).
|
|
Stats(String),
|
|
/// The child exited (stdout EOF + reap; a kill lands here too). `error`/`ended`
|
|
/// carry the contract lines seen on the way out, when any (the exit code is logged
|
|
/// by the reader; routing keys off the lines, which say strictly more).
|
|
Exited {
|
|
error: Option<(String, bool)>,
|
|
ended: Option<String>,
|
|
},
|
|
}
|
|
|
|
/// Kills the spawned session child (the Disconnect button, request-access Cancel). Safe
|
|
/// to call any time; a child that already exited is a no-op. A FRESH handle is installed
|
|
/// per spawn (`Shared::session`) so a stale handle can never kill a newer session.
|
|
#[derive(Clone, Default)]
|
|
pub(crate) struct SessionChild(Arc<Mutex<Option<Child>>>);
|
|
|
|
impl SessionChild {
|
|
pub(crate) fn kill(&self) {
|
|
if let Some(child) = self.0.lock().unwrap().as_mut() {
|
|
let _ = child.kill();
|
|
}
|
|
}
|
|
|
|
/// Whether a spawned child is currently live (spawned and not yet reaped by its
|
|
/// reader). The probe sweep pauses while one runs — the shell is hidden, and probing
|
|
/// the host we're streaming from is just noise.
|
|
pub(crate) fn is_running(&self) -> bool {
|
|
self.0.lock().unwrap().is_some()
|
|
}
|
|
}
|
|
|
|
/// One parsed stdout line of the session contract; `None` for anything unrecognized.
|
|
enum ChildLine {
|
|
Ready,
|
|
Error { msg: String, trust_rejected: bool },
|
|
Ended(String),
|
|
Stats(String),
|
|
}
|
|
|
|
fn parse_line(line: &str) -> Option<ChildLine> {
|
|
if let Some(stats) = line.strip_prefix("stats: ") {
|
|
return Some(ChildLine::Stats(stats.to_string()));
|
|
}
|
|
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(ChildLine::Ready);
|
|
}
|
|
if let Some(msg) = v.get("error").and_then(|m| m.as_str()) {
|
|
return Some(ChildLine::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(ChildLine::Ended(msg.to_string()));
|
|
}
|
|
None
|
|
}
|
|
|
|
/// The session binary: installed next to the shell (the MSIX layout and dev
|
|
/// `target\…` runs both land on the sibling), else `PATH`.
|
|
pub(crate) fn session_binary() -> std::path::PathBuf {
|
|
if let Ok(exe) = std::env::current_exe() {
|
|
let sibling = exe.with_file_name("punktfunk-session.exe");
|
|
if sibling.exists() {
|
|
return sibling;
|
|
}
|
|
}
|
|
"punktfunk-session".into()
|
|
}
|
|
|
|
/// Spawn the session binary for a connect with `fp_hex` pinned and feed its lifecycle to
|
|
/// `on_event` from a reader thread. The child is parked in `slot` so Disconnect/Cancel
|
|
/// can kill it. `fullscreen` starts the stream window fullscreen (the Settings "Start
|
|
/// streams fullscreen" toggle); `launch` carries a library title id for the host to
|
|
/// launch during the handshake. `Err` = the spawn itself failed (binary missing?) —
|
|
/// surfaced as a connect error by the caller.
|
|
#[allow(clippy::too_many_arguments)] // one cohesive spawn spec (session_params precedent)
|
|
pub(crate) fn spawn_session(
|
|
addr: &str,
|
|
port: u16,
|
|
fp_hex: &str,
|
|
connect_timeout_secs: u64,
|
|
fullscreen: bool,
|
|
launch: Option<&str>,
|
|
slot: SessionChild,
|
|
on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
|
) -> Result<(), String> {
|
|
let mut cmd = Command::new(session_binary());
|
|
cmd.arg("--connect")
|
|
.arg(format!("{addr}:{port}"))
|
|
.arg("--fp")
|
|
.arg(fp_hex)
|
|
.arg("--connect-timeout")
|
|
.arg(connect_timeout_secs.to_string());
|
|
if fullscreen {
|
|
cmd.arg("--fullscreen");
|
|
}
|
|
if let Some(id) = launch {
|
|
cmd.arg("--launch").arg(id);
|
|
}
|
|
add_window_pos(&mut cmd);
|
|
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
|
|
}
|
|
|
|
/// Spawn the session binary in `--browse` mode: the console (gamepad) library for a
|
|
/// PAIRED host, in the session window — launches run as streams in that same window.
|
|
/// The same stdout contract as a connect (`--json-status`): `ready` when the library
|
|
/// window presents, `error` on a failed start, EOF on quit.
|
|
pub(crate) fn spawn_browse(
|
|
addr: &str,
|
|
port: u16,
|
|
fullscreen: bool,
|
|
slot: SessionChild,
|
|
on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
|
) -> Result<(), String> {
|
|
let mut cmd = Command::new(session_binary());
|
|
cmd.arg("--browse")
|
|
.arg(format!("{addr}:{port}"))
|
|
.arg("--json-status");
|
|
if fullscreen {
|
|
cmd.arg("--fullscreen");
|
|
}
|
|
add_window_pos(&mut cmd);
|
|
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
|
|
}
|
|
|
|
/// Hand the shell window's position to the child (`--window-pos`) so the session window
|
|
/// opens on the same monitor, where the shell is — the hide/restore handoff then reads as
|
|
/// one window changing content instead of a window jumping displays.
|
|
fn add_window_pos(cmd: &mut Command) {
|
|
if let Some((x, y)) = crate::shell_window::position() {
|
|
cmd.arg("--window-pos").arg(format!("{x},{y}"));
|
|
}
|
|
}
|
|
|
|
/// The shared spawn + stdout-contract reader behind [`spawn_session`]/[`spawn_browse`].
|
|
fn spawn_with(
|
|
mut cmd: Command,
|
|
host_label: &str,
|
|
slot: SessionChild,
|
|
mut on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
|
) -> Result<(), String> {
|
|
use std::os::windows::process::CommandExt as _;
|
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
|
|
cmd.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::inherit()) // session logs interleave with the shell's (dev runs)
|
|
.creation_flags(CREATE_NO_WINDOW);
|
|
let mut child = cmd
|
|
.spawn()
|
|
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
|
|
tracing::info!(host = %host_label, "session binary spawned");
|
|
|
|
let stdout = child.stdout.take().expect("piped stdout");
|
|
// Park the child where the kill handle (and the reader, for the final reap) reach it.
|
|
*slot.0.lock().unwrap() = Some(child);
|
|
|
|
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(ChildLine::Ready) => on_event(SpawnEvent::Ready),
|
|
Some(ChildLine::Stats(s)) => on_event(SpawnEvent::Stats(s)),
|
|
Some(ChildLine::Error {
|
|
msg,
|
|
trust_rejected,
|
|
}) => error = Some((msg, trust_rejected)),
|
|
Some(ChildLine::Ended(msg)) => ended = Some(msg),
|
|
None => {}
|
|
}
|
|
}
|
|
// EOF — reap the child (killed-by-Disconnect lands here too; -1 = no code).
|
|
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");
|
|
on_event(SpawnEvent::Exited { error, ended });
|
|
})
|
|
.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(ChildLine::Ready)
|
|
));
|
|
match parse_line("{\"error\":\"no route\",\"trust_rejected\":false}") {
|
|
Some(ChildLine::Error {
|
|
msg,
|
|
trust_rejected,
|
|
}) => {
|
|
assert_eq!(msg, "no route");
|
|
assert!(!trust_rejected);
|
|
}
|
|
_ => panic!("error line"),
|
|
}
|
|
match parse_line("{\"error\":\"pin\",\"trust_rejected\":true}") {
|
|
Some(ChildLine::Error { trust_rejected, .. }) => assert!(trust_rejected),
|
|
_ => panic!("trust line"),
|
|
}
|
|
match parse_line("{\"ended\":\"Host ended the session\"}") {
|
|
Some(ChildLine::Ended(m)) => assert_eq!(m, "Host ended the session"),
|
|
_ => panic!("ended line"),
|
|
}
|
|
// Stats lines become Stats events; stray output never becomes an event.
|
|
match parse_line("stats: 1280\u{00D7}800@60 \u{00B7} 60 fps") {
|
|
Some(ChildLine::Stats(s)) => assert!(s.starts_with("1280")),
|
|
_ => panic!("stats line"),
|
|
}
|
|
assert!(parse_line("").is_none());
|
|
assert!(parse_line("{\"other\":1}").is_none());
|
|
}
|
|
}
|