Files
punktfunk/crates/pf-client-core/src/orchestrate.rs
T
enricobuehlerandClaude Opus 5 5f55fa874a feat(client/present): the desktop presenter gains the Apple/Android intent model
WP1+WP2 of design/desktop-presentation-rebuild.md. The shared Linux/Windows
session client presented arrival-paced with no pacing layer at all: two depth-2
newest-wins hops into a drain-to-newest and an immediate present. That IS the
lowest-latency intent, but it was unnamed, unselectable, and had no alternative
— and on a surface without MAILBOX (AMD's Windows driver offers none, and any
compositor holding images does the same) the swapchain's own FIFO becomes a
standing queue worth a measured 11-13 ms at 60 Hz.

WP1 — the settings cluster, under the keys the Apple client already writes into
the shared profile catalog (present_priority / smooth_buffer / vsync /
allow_vrr): mismatched names would ride SettingsOverlay::extra, carried but
never applied. PresentPriority::resolve mirrors the Android reference exactly
(anything but an explicit "smooth" is latency; a buffer outside 1..=3 becomes
2), so a profile authored on any client means the same thing on all of them.
Only the first two are consumed here; vsync/allow_vrr land in WP3.

WP2 — the engine (present_pace.rs, pure state + arithmetic, 6 tests):
- FrameStore: newest-wins slot, or the smoothing FIFO with preroll-to-capacity,
  drop-oldest overflow, and an underflow that re-arms the preroll (repeat by
  omission) — the Apple/Android semantics, with qDrop/qDry counters.
- LatchClock: the panel grid learned from VK_KHR_present_wait glass stamps,
  min positive spacing capped by the mode refresh (measured, never queried —
  VRR and Android's per-uid refresh lie both punish trusting a reported rate).
  It now also publishes the host-facing LatchGrid, so the phase-lock report and
  the local scheduler cannot disagree about the grid.
- PresentGate: one undisplayed present in flight on FIFO surfaces, with the
  100 ms stale force-open. This is the standing-queue killer, and it is inert
  on MAILBOX/IMMEDIATE and without present timing — where behaviour stays
  byte-for-byte the shipped arrival pacing.

Wiring: glass samples drain every pass (a 1 Hz batch would starve clock and
gate) and the waiter pushes an SDL wake, so a gate reopen never waits out the
event timeout; smoothness serves one frame per latch slot and tightens the
loop's wait to that deadline; the adaptive slot margin starts at 0 and widens
+500 us per missed window toward 2.5 ms (a fixed lead was measured to be pure
display tax). PUNKTFUNK_PRESENTER=arrival disables the whole engine for field
A/B without a rebuild.

PyroWave collapses smoothness to latency for the stream: its plane-ring
retirement accounting assumes the depth-2 newest-wins hand-off, and all-intra
frames make buffering moot anyway.

Gates (punktfunk-rust-ci, linux/amd64, sources touched first so a warm target
cannot print a vacuous Finished): clippy -D warnings across pf-client-core,
pf-presenter and punktfunk-client-session; 80 + 32 tests pass; rustfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:00:50 +02:00

1077 lines
45 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The brain layer: what a connect *is*, and the one implementation of how it runs
//! (design/client-architecture-split.md §3).
//!
//! Wake-then-connect exists three times today — GTK's `WakeConnect`/`wake_fallback`, the
//! WinUI shell's `wake_and_connect`, Apple's `HostWaker` (whose comment in the Windows copy
//! literally says "mirrors the Apple HostWaker") — and the deep-link and profile work would
//! have made it five. This module is where that collapses: a [`ConnectPlan`] is built from a
//! card click, a CLI verb or a URL (one constructor each, one type out), and the orchestrator
//! runs it. Front-ends render; they don't decide.
//!
//! The split that keeps this honest is [`UiDelegate`]: prompts, progress and error surfaces
//! stay in the front-end, because a GTK dialog, a WinUI page, a Skia console screen and a
//! terminal prompt genuinely are different things — but *when* to prompt, *how long* to wait
//! for a sleeping box and *what counts as a refusal* are decided here, once.
//!
//! Wake timings are Apple's `HostWaker` verbatim, because it is the implementation that got
//! them right: a magic packet is fire-and-forget and a cold box takes 2060 s to POST, boot
//! and re-advertise — far longer than any dial will sit — so the packet is re-sent every 6 s
//! (a single one gets missed, and some NICs only wake on a fresh packet after dropping into a
//! deeper sleep state), presence is polled once a second, and the whole wait is bounded at
//! 90 s, after which it PARKS for retry rather than erroring out from under the user.
use crate::deeplink::{DeepLink, HostResolution, Route};
use crate::profiles::{ProfilesFile, Resolution, StreamProfile};
use crate::trust::{effective_settings, KnownHost, KnownHosts, Settings};
use serde::{Deserialize, Serialize};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
/// The host a plan dials, flattened out of whichever record or reference produced it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HostTarget {
pub name: String,
pub addr: String,
pub port: u16,
/// The pinned fingerprint. `None` = no pin, which the session binary refuses by design —
/// a plan without one may only exist after the front-end's trust ceremony.
pub fp_hex: Option<String>,
pub mac: Vec<String>,
pub id: Option<String>,
}
impl From<&KnownHost> for HostTarget {
fn from(h: &KnownHost) -> HostTarget {
HostTarget {
name: h.name.clone(),
addr: h.addr.clone(),
port: h.port,
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
mac: h.mac.clone(),
id: h.id.clone(),
}
}
}
/// A resolved intent: everything needed to start one session, with every policy question
/// already answered. Built once, then executed — front-ends don't re-decide any of it.
#[derive(Clone, Debug, PartialEq)]
pub struct ConnectPlan {
pub host: HostTarget,
/// Library id for the host to launch on arrival.
pub launch: Option<String>,
/// The settings profile this connect resolved with (display + the stats overlay).
pub profile: Option<StreamProfile>,
/// The one-off profile reference to hand the session, if this connect overrides the host's
/// binding: `Some(id)` for "Connect with ▸ X", `Some("")` to force the defaults, `None` to
/// let the session resolve the host's own binding (the two paths call the same resolver,
/// so they cannot disagree).
pub profile_override: Option<String>,
/// Effective settings — global defaults with the profile overlaid. What the front-end
/// reads for anything it needs to know up front (fullscreen, match-window…).
pub settings: Settings,
/// Send a magic packet up front and fall back to wake-and-wait if the dial fails. Off for
/// hosts with no MAC, and when the user turned auto-wake off (VPN hosts look offline when
/// they aren't, and the wake+wait only adds delay).
pub wake: bool,
/// Handshake budget override — the request-access flow passes ~185 s because the host
/// PARKS the connection until an operator approves.
pub connect_timeout_secs: Option<u64>,
/// The pin came from an advert rather than the store: persist it once the session reports
/// ready (ready proves the host really holds that identity).
pub tofu: bool,
/// Share this machine's clipboard with this host — a trust decision about the HOST, so it
/// lives on the record rather than in a profile, and the spawner resolves it once here
/// instead of the renderer looking it up again.
pub clipboard: bool,
}
impl ConnectPlan {
/// The plain card-click plan: this host, its binding (or a one-off profile), its wake
/// policy. `one_off_profile` is the "Connect with ▸" pick — `Some("")` forces the global
/// defaults on a bound host, `None` honors the binding. Loads the stores; the pure form is
/// [`ConnectPlan::resolve`], which a front-end that already holds them should use.
pub fn for_host(
host: &KnownHost,
launch: Option<&str>,
one_off_profile: Option<&str>,
) -> ConnectPlan {
let (settings, profile) = effective_settings(&host.addr, host.port, one_off_profile);
ConnectPlan {
host: HostTarget::from(host),
launch: launch.map(str::to_string),
profile,
profile_override: one_off_profile.map(str::to_string),
wake: settings.auto_wake && !host.mac.is_empty(),
settings,
connect_timeout_secs: None,
tofu: false,
clipboard: host.clipboard_sync,
}
}
/// The plan for a host a front-end carries in its OWN request type rather than as a stored
/// [`KnownHost`] (the GTK shell's `ConnectRequest`, a resolved link target). Settings,
/// profile and the clipboard decision are resolved here, through the same helpers
/// [`ConnectPlan::for_host`] uses; the caller then sets only what is genuinely its own
/// (`wake` when it runs its own wake fallback, `tofu`, `connect_timeout_secs`).
///
/// It exists because hand-building the struct is a trap. [`spawn_session`] writes
/// `settings` into the child's `--resolved-spec`, and a session running from a spec reads
/// NO stores — so a `..Settings::default()` in a front-end does not fall back to the
/// user's settings, it silently streams at every default: the host's fallback bitrate, the
/// native resolution, `auto` codec, stereo audio. That shipped on the GTK shell (fixed
/// 2026-07-31) and presented as "my bitrate is stuck at 20 Mbps".
pub fn for_target(
host: HostTarget,
launch: Option<String>,
one_off_profile: Option<String>,
) -> ConnectPlan {
let known = KnownHosts::load();
// No record yet — a first connect straight off an advert. A default record says
// exactly the right thing: no profile binding, no clipboard opt-in.
let fallback = KnownHost::default();
let stored = known
.find_by_addr(&host.addr, host.port)
.unwrap_or(&fallback);
let mut plan = ConnectPlan::resolve(
stored,
launch.as_deref(),
one_off_profile.as_deref(),
&ProfilesFile::load(),
&Settings::load(),
);
// The CALLER's target wins over the stored record's: its fingerprint can be one the
// host just advertised and we haven't persisted (trust on first use), and its name/MAC
// come from the same discovery snapshot the card was drawn from — so `wake` has to be
// re-decided against that MAC rather than the record's.
plan.wake = plan.settings.auto_wake && !host.mac.is_empty();
plan.host = host;
plan
}
/// The same plan, built from stores the caller already has — no disk, no clock, no
/// environment. This is the form the URL router uses: a front-end loads the three stores
/// once per event and every decision below is a pure function of them.
///
/// Profile precedence is the design's, unchanged: the one-off pick, else the host's
/// binding, else nothing; `Some("")` forces the defaults; anything dangling resolves as
/// no profile rather than an error.
pub fn resolve(
host: &KnownHost,
launch: Option<&str>,
one_off_profile: Option<&str>,
catalog: &ProfilesFile,
base: &Settings,
) -> ConnectPlan {
let profile = match one_off_profile {
Some("") => None,
Some(reference) => catalog.resolve(reference).0.cloned(),
None => host
.profile_id
.as_deref()
.and_then(|id| catalog.find_by_id(id))
.cloned(),
};
let settings = match &profile {
Some(p) => p.overrides.apply(base),
None => base.clone(),
};
ConnectPlan {
host: HostTarget::from(host),
launch: launch.map(str::to_string),
profile,
profile_override: one_off_profile.map(str::to_string),
wake: settings.auto_wake && !host.mac.is_empty(),
settings,
connect_timeout_secs: None,
tofu: false,
clipboard: host.clipboard_sync,
}
}
/// This plan as a [`ResolvedSpec`] — what a first-party spawner hands the session so it
/// performs no store reads of its own.
pub fn spec(&self, clipboard: bool) -> ResolvedSpec {
ResolvedSpec {
settings: self.settings.clone(),
clipboard,
profile: self.profile.as_ref().map(|p| p.name.clone()),
}
}
/// The session binary's argv for this plan — the one place the flags are assembled, so a
/// shell, the CLI and a URL launch cannot spawn subtly different sessions.
pub fn session_args(&self) -> Vec<String> {
let mut args = vec![
"--connect".into(),
format!("{}:{}", self.host.addr, self.host.port),
];
if let Some(fp) = &self.host.fp_hex {
args.push("--fp".into());
args.push(fp.clone());
}
if let Some(launch) = &self.launch {
args.push("--launch".into());
args.push(launch.clone());
}
// Only a one-off rides the flag: without it the session resolves the host's own
// binding through the same helper this plan used.
if let Some(profile) = &self.profile_override {
args.push("--profile".into());
args.push(profile.clone());
}
if let Some(secs) = self.connect_timeout_secs {
args.push("--connect-timeout".into());
args.push(secs.to_string());
}
if self.settings.fullscreen_on_stream {
args.push("--fullscreen".into());
}
// Deliberately NO `--window-pos` here. The Windows shell appends its own (its
// window's desktop coordinates place the session on the same monitor), but on
// Wayland neither GTK can read global window coordinates nor can SDL apply
// them — the compositor owns placement — so from the GTK/CLI spawners the flag
// would be a silent no-op everywhere it matters. X11 could carry it, but a
// Linux-only special case that most Linux sessions ignore isn't worth the drift.
args
}
}
/// What a URL turned into. Everything a front-end must not decide for itself lives in this
/// enum: an unknown host is a *prompt*, never a connect, and a route this build doesn't do is
/// a notice, never a silent no-op.
#[derive(Clone, Debug, PartialEq)]
pub enum PlanOutcome {
Connect(Box<ConnectPlan>),
/// The link resolved to no local record. The front-end shows the confirmation sheet with
/// exactly this, and the normal pairing/TOFU flow proceeds under the user's eyes (§3.1).
ConfirmUnknown(Box<UnknownHost>),
/// A route the grammar defines but this front-end hasn't implemented yet.
Unsupported(Route),
}
/// The confirmation sheet's contents for a link to a host we don't know.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UnknownHost {
pub addr: String,
pub port: u16,
/// The label the link claimed — shown as *claimed*, never trusted.
pub name: Option<String>,
/// The fingerprint the link expects; pre-fills the sheet's pin so the first connect is
/// verified rather than blind trust-on-first-use.
pub fp: Option<String>,
pub launch: Option<String>,
pub profile: Option<String>,
}
/// Why a link can't become a plan. Each of these is a *notice*, never a degraded connect:
/// predictability over best-effort — a shortcut that silently streams with the wrong settings
/// or to the wrong box is worse than one that explains itself (design/client-deep-links.md §8).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PlanError {
/// The host name matched more than one saved host.
AmbiguousHost(String),
/// Nothing local matched and the link carries no address to fall back on.
UnresolvableHost(String),
/// The link's `fp` contradicts the pin we hold — the link is stale or lying.
PinConflict {
host: String,
},
UnknownProfile(String),
AmbiguousProfile(String),
}
impl PlanError {
/// The notice text. Every one of these names the reference that failed, because "it didn't
/// work" on a shortcut double-click is unactionable.
pub fn message(&self) -> String {
match self {
PlanError::AmbiguousHost(r) => {
format!("More than one saved host is called \"{r}\" — open Punktfunk and pick one.")
}
PlanError::UnresolvableHost(r) => {
format!("No saved host matches \"{r}\".")
}
PlanError::PinConflict { host } => format!(
"That link's fingerprint doesn't match the one saved for {host} — it's out of \
date, or it isn't that host. Nothing was connected."
),
PlanError::UnknownProfile(p) => {
format!("That link asks for a settings profile called \"{p}\", which doesn't exist here.")
}
PlanError::AmbiguousProfile(p) => {
format!("More than one settings profile is called \"{p}\" — rename one, or use its id in the link.")
}
}
}
}
/// Build a plan from a `punktfunk://` link against this device's stores — the shared half of
/// every platform's URL router (§4). The security rules of §3 live here, not in the shells:
/// no pairing, no silent trust, references resolved or refused.
///
/// Preempting a live session is the one rule that stays with the caller: only the front-end
/// knows whether a session is running, and the answer ("focus it" / "end that one first")
/// is UI, not policy.
pub fn plan_from_link(
link: &DeepLink,
known: &KnownHosts,
catalog: &ProfilesFile,
base: &Settings,
) -> Result<PlanOutcome, PlanError> {
if link.route != Route::Connect {
return Ok(PlanOutcome::Unsupported(link.route));
}
// The profile is resolved BEFORE anything is dialled: a link that can't honor its profile
// must say so instead of streaming with the wrong settings.
if let Some(reference) = &link.profile {
match catalog.resolve(reference) {
(Some(_), _) => {}
(_, Resolution::Ambiguous) => {
return Err(PlanError::AmbiguousProfile(reference.clone()))
}
_ => return Err(PlanError::UnknownProfile(reference.clone())),
}
}
match crate::deeplink::resolve_host(link, known) {
HostResolution::Known(i) => {
let host = &known.hosts[i];
if link.pin_conflict(host) {
return Err(PlanError::PinConflict {
host: host.name.clone(),
});
}
// A link with no `profile=` honors the host's binding, exactly like a card
// click — the URL adds nothing there, so it changes nothing.
let mut plan = ConnectPlan::resolve(
host,
link.launch.as_deref(),
link.profile.as_deref(),
catalog,
base,
);
// A record we know but never pinned (added by address, never paired) is not a
// silent connect either: the session refuses without a pin, and the front-end
// should run its trust flow. Hand it back as the confirmation case.
if plan.host.fp_hex.is_none() {
return Ok(PlanOutcome::ConfirmUnknown(Box::new(UnknownHost {
addr: plan.host.addr,
port: plan.host.port,
name: Some(plan.host.name),
fp: link.fp.clone(),
launch: link.launch.clone(),
profile: link.profile.clone(),
})));
}
if plan.host.name.is_empty() {
// An address-only record has no label; the link's claimed one is fine for a
// window title (it names nothing that is trusted).
plan.host.name = link.name.clone().unwrap_or_else(|| plan.host.addr.clone());
}
Ok(PlanOutcome::Connect(Box::new(plan)))
}
HostResolution::Unknown {
addr,
port,
name,
fp,
} => Ok(PlanOutcome::ConfirmUnknown(Box::new(UnknownHost {
addr,
port,
name,
fp,
launch: link.launch.clone(),
profile: link.profile.clone(),
}))),
HostResolution::Ambiguous => Err(PlanError::AmbiguousHost(link.host_ref.clone())),
HostResolution::Unresolvable => Err(PlanError::UnresolvableHost(link.host_ref.clone())),
}
}
// ---------------------------------------------------------------------------------------
// Wake-and-wait — the reference state machine, ported from Apple's `HostWaker`.
// ---------------------------------------------------------------------------------------
/// How long to wait for a woken host to come back. Generous on purpose: a cold boot plus
/// service start is routinely a minute-plus.
pub const WAKE_TIMEOUT_SECS: u64 = 90;
/// How often to re-send the magic packet while waiting.
pub const WAKE_RESEND_SECS: u64 = 6;
/// The wake-and-wait loop as a pure step function, so every front-end drives it from its own
/// loop (relm4 messages, a WinUI thread, the console's service tick, a CLI's sleep) and they
/// all still agree on the timings — and so the behavior is testable without waiting 90 s.
#[derive(Clone, Debug)]
pub struct WakeWait {
elapsed_secs: u64,
timeout_secs: u64,
resend_secs: u64,
}
/// What the caller should do for this one-second step.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct WakeTick {
/// Send (or re-send) the magic packet now.
pub send_packet: bool,
/// Seconds waited so far — the "Waking… 12s" line.
pub seconds: u64,
/// `None` = keep waiting (sleep a second, then tick again).
pub outcome: Option<WakeOutcome>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WakeOutcome {
/// The host answered — proceed with the connect.
Online,
/// The budget ran out. The UI PARKS here (Try again / Cancel); it does not error out
/// from under the user, because "it didn't wake in 90 s" is often "give it 10 more".
TimedOut,
}
impl Default for WakeWait {
fn default() -> WakeWait {
WakeWait {
elapsed_secs: 0,
timeout_secs: WAKE_TIMEOUT_SECS,
resend_secs: WAKE_RESEND_SECS,
}
}
}
impl WakeWait {
/// A wait with the shipped timings.
pub fn new() -> WakeWait {
WakeWait::default()
}
/// One second of the wait. `online` is this tick's presence reading (an mDNS advert, a
/// reachability probe — whichever the front-end has; both are "did it answer").
///
/// Order matters and matches the reference: the packet goes out *before* the presence
/// check, so an already-awake host costs one wasted packet rather than a lost second, and
/// the timeout is checked after it — a host that appears on the last tick still wins.
pub fn tick(&mut self, online: bool) -> WakeTick {
let send_packet = self.elapsed_secs % self.resend_secs == 0;
let seconds = self.elapsed_secs;
let outcome = if online {
Some(WakeOutcome::Online)
} else if self.elapsed_secs >= self.timeout_secs {
Some(WakeOutcome::TimedOut)
} else {
self.elapsed_secs += 1;
None
};
WakeTick {
send_packet,
seconds,
outcome,
}
}
/// Restart the same wait — "Try again" after a timeout replays it exactly (the reference's
/// captured `replay` closure, minus the closure).
pub fn restart(&mut self) {
self.elapsed_secs = 0;
}
pub fn seconds(&self) -> u64 {
self.elapsed_secs
}
}
/// The front-end's obligations. Everything here is presentation; nothing here decides policy.
pub trait UiDelegate {
/// A link or a card points at a host we don't know (or never pinned). Return true to
/// proceed into the trust flow. A non-interactive front-end returns false — refusing is
/// always safe, and the CLI reports it as "needs interaction" rather than pairing blind.
fn confirm_unknown_host(&mut self, host: &UnknownHost) -> bool;
/// Render "Waking <host>… 12s" / the timed-out park state.
fn wake_progress(&mut self, host: &HostTarget, tick: WakeTick);
/// The session ended, one way or another.
fn report(&mut self, outcome: &ConnectOutcome);
}
/// How a connect finished — the typed outcome every front-end maps onto its own surface.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ConnectOutcome {
/// The stream ran and ended cleanly; `Some` carries the host's stated reason.
Ended(Option<String>),
/// The dial failed (and, where applicable, the wake wait did too).
ConnectFailed(String),
/// Trust rejected: no pin, or the pin no longer matches. Never retried silently.
TrustRejected(String),
/// The session binary itself failed to start or died abnormally.
RendererFailed(String),
/// The user cancelled.
Cancelled,
}
// ---------------------------------------------------------------------------------------
// Session spawn + the stdout contract.
// ---------------------------------------------------------------------------------------
/// Everything a session needs, resolved by the caller — the spec `--resolved-spec` carries
/// (design/client-architecture-split.md §5).
///
/// The session binary is a renderer: given this, it performs ZERO store reads. Its old habit of
/// re-deriving state (loading `Settings`, looking up the host's `clipboard_sync`, resolving the
/// profile) meant policy was being evaluated inside the thing that draws pixels, and that the
/// spawner and the child could disagree about a file either of them might have written since.
///
/// The compat path — a hand-run `punktfunk-session --connect` with no spec — still resolves for
/// itself, but through the *same* helper (`effective_settings`), so the two modes cannot drift.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ResolvedSpec {
/// Effective settings: the global defaults with the chosen profile already applied.
pub settings: Settings,
/// Whether this host may share the clipboard — a per-host trust decision, resolved by the
/// spawner rather than re-looked-up here.
pub clipboard: bool,
/// The profile's name, for the stats overlay. `None` = the global defaults.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
}
impl ResolvedSpec {
/// Write the spec somewhere the child can read it, returning the path. Temp files rather
/// than a pipe because the session already takes a file path elsewhere and a crashed
/// spawner leaves something inspectable; the name carries the pid so concurrent launches
/// (a shell and a CLI, or two Decky invocations) never overwrite each other's spec.
///
/// The pid alone was not enough: it is the SPAWNER's, so two launches from one shell — a
/// cancelled connect and the retry right behind it — shared a single path, and the first
/// child's exit deleted the file the second was still starting up to read ("resolved spec:
/// No such file"). A per-launch counter separates them.
pub fn write_temp(&self) -> std::io::Result<std::path::PathBuf> {
static LAUNCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = LAUNCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("punktfunk-spec-{}-{n}.json", std::process::id()));
let json = serde_json::to_vec_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&path, json)?;
Ok(path)
}
/// Read a spec written by the spawner.
pub fn read(path: &std::path::Path) -> std::io::Result<ResolvedSpec> {
let text = std::fs::read_to_string(path)?;
serde_json::from_str(&text)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
}
/// One event from the session child's stdout contract (`{"ready":true}`, `{"error":…}`,
/// `{"ended":…}`, then EOF and an exit code). Parsed in one place so a shell, the console and
/// the CLI cannot disagree about what "ready" or "trust rejected" means.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SessionEvent {
/// First frame presented — the stream is up.
Ready,
Error {
msg: String,
trust_rejected: bool,
},
Ended(String),
/// The session window's logical size settled at this, under the match-window policy. The
/// SPAWNER persists it (design §5): a renderer that load-modify-saves the shared settings
/// file was one of its five concurrent writers, for a value only the parent needs.
Window {
w: u32,
h: u32,
},
/// EOF: the child is gone. `-1` = killed by a signal.
Exited(i32),
}
/// Parse one stdout line of the session contract; `None` for anything else (`stats:` lines,
/// stray output).
pub fn parse_session_line(line: &str) -> Option<SessionEvent> {
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(SessionEvent::Ready);
}
if let Some(msg) = v.get("error").and_then(|m| m.as_str()) {
return Some(SessionEvent::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(SessionEvent::Ended(msg.to_string()));
}
if let Some(win) = v.get("window") {
let dim = |k: &str| win.get(k).and_then(|n| n.as_u64()).map(|n| n as u32);
if let (Some(w), Some(h)) = (dim("w"), dim("h")) {
return Some(SessionEvent::Window { w, h });
}
}
None
}
/// Persist a window size the session reported. The spawner's job now, not the renderer's — and
/// it writes only on a real change, so a session that never resizes never touches the file.
pub fn persist_window_size(w: u32, h: u32) {
let mut s = Settings::load();
if (s.last_window_w, s.last_window_h) != (w, h) {
s.last_window_w = w;
s.last_window_h = h;
s.save();
}
}
/// The session binary: installed next to this executable, else `$PATH` (a dev run out of
/// `target/…` lands on the sibling).
pub fn session_binary() -> std::path::PathBuf {
if let Ok(exe) = std::env::current_exe() {
let sibling = exe.with_file_name(SESSION_BIN);
if sibling.exists() {
return sibling;
}
}
SESSION_BIN.into()
}
#[cfg(windows)]
const SESSION_BIN: &str = "punktfunk-session.exe";
#[cfg(not(windows))]
const SESSION_BIN: &str = "punktfunk-session";
/// Kills the spawned session child — the Cancel button of a parked request-access connect,
/// and the CLI's Ctrl-C path. Safe 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();
}
}
}
/// Spawn the session for this plan and supervise its stdout contract on a reader thread,
/// handing each event to `on_event` (which every front-end maps onto its own messages). The
/// final [`SessionEvent::Exited`] always arrives, so a caller can release its busy flag in
/// exactly one place.
/// `cancel` lets a front-end hold the abort handle BEFORE the child exists (a request-access
/// dialog arms its Cancel button first, then spawns); pass `None` to get a fresh one back.
pub fn spawn_session(
plan: &ConnectPlan,
cancel: Option<CancelHandle>,
on_event: impl FnMut(SessionEvent) + Send + 'static,
) -> Result<CancelHandle, String> {
let mut cmd = Command::new(session_binary());
let mut args = plan.session_args();
// Spec mode: hand the child the settings we already resolved, so it reads no stores and
// cannot disagree with us about a file either of us might write (design §5). A spec we
// fail to write is not fatal — the child's compat path resolves the same values through
// the same helper, which is exactly why that path was kept.
let spec_path = match plan.spec(plan.clipboard).write_temp() {
Ok(path) => {
args.push("--resolved-spec".into());
args.push(path.to_string_lossy().into_owned());
Some(path)
}
Err(e) => {
tracing::warn!(error = %e, "couldn't write the resolved spec; the session will resolve for itself");
None
}
};
cmd.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit()); // the session's logs interleave with the front-end's
let mut child = cmd
.spawn()
.map_err(|e| format!("couldn't start {}: {e}", SESSION_BIN))?;
tracing::info!(
host = %plan.host.addr, port = plan.host.port,
profile = plan.profile.as_ref().map(|p| p.name.as_str()).unwrap_or("-"),
"session binary spawned"
);
let stdout = child.stdout.take().expect("piped stdout");
let slot = cancel.unwrap_or_default();
*slot.0.lock().unwrap() = Some(child);
let reader_slot = slot.clone();
let mut on_event = on_event;
std::thread::Builder::new()
.name("pf-session-io".into())
.spawn(move || {
use std::io::BufRead as _;
for line in std::io::BufReader::new(stdout).lines() {
let Ok(line) = line else { break };
if let Some(ev) = parse_session_line(&line) {
// The window size is the spawner's to persist — the renderer only reports
// it. Front-ends still see the event; they just don't have to act on it.
if let SessionEvent::Window { w, h } = ev {
persist_window_size(w, h);
}
on_event(ev);
}
}
// The spec has done its job the moment the child has read it; a leftover temp file
// in %TEMP% is litter, and one per launch adds up.
if let Some(path) = &spec_path {
let _ = std::fs::remove_file(path);
}
// EOF — reap (a cancel-killed child lands here too; -1 = died on a signal).
let code = reader_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(SessionEvent::Exited(code));
})
.map_err(|e| format!("session reader thread: {e}"))?;
Ok(slot)
}
/// Become the session process (`--exec`): the CLI's gamescope-wrapper mode, where the launched
/// process identity must be the streaming one — a supervising parent would break focus and
/// lifecycle under gamescope. Never returns on success. Windows has no `exec`, so there this
/// runs the child to completion and exits with its code, which is the same contract minus the
/// pid.
pub fn exec_session(plan: &ConnectPlan) -> std::io::Error {
let mut cmd = Command::new(session_binary());
cmd.args(plan.session_args());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
cmd.exec()
}
#[cfg(not(unix))]
{
match cmd.status() {
Ok(s) => std::process::exit(s.code().unwrap_or(1)),
Err(e) => e,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::deeplink;
fn host(name: &str, addr: &str, id: &str, fp: &str) -> KnownHost {
KnownHost {
name: name.into(),
addr: addr.into(),
port: 9777,
fp_hex: fp.into(),
paired: true,
mac: vec!["aa:bb:cc:dd:ee:ff".into()],
id: Some(id.into()),
..Default::default()
}
}
/// The wait is Apple's `HostWaker` second for second: a packet at 0 and every 6 s after,
/// a presence check each second, 90 s of budget, and a park (not an error) at the end.
#[test]
fn wake_wait_matches_the_reference_cadence() {
let mut w = WakeWait::new();
// t=0: packet goes out before the first presence check.
let t = w.tick(false);
assert!(t.send_packet);
assert_eq!(t.seconds, 0);
assert_eq!(t.outcome, None);
// t=1..5 wait quietly, t=6 re-sends.
for s in 1..6 {
let t = w.tick(false);
assert!(!t.send_packet, "no packet at {s}s");
assert_eq!(t.seconds, s);
}
assert!(w.tick(false).send_packet); // t=6
assert_eq!(w.seconds(), 7);
// A host that answers ends the wait immediately, whatever second it is.
let mut w = WakeWait::new();
w.tick(false);
let t = w.tick(true);
assert_eq!(t.outcome, Some(WakeOutcome::Online));
// The budget: still waiting at 90 s of elapsed time, timed out on the tick after.
let mut w = WakeWait::new();
for _ in 0..WAKE_TIMEOUT_SECS {
assert_eq!(w.tick(false).outcome, None);
}
assert_eq!(w.seconds(), WAKE_TIMEOUT_SECS);
let t = w.tick(false);
assert_eq!(t.outcome, Some(WakeOutcome::TimedOut));
// A timed-out wait doesn't advance — it parks, and stays parked until asked again.
assert_eq!(w.tick(false).outcome, Some(WakeOutcome::TimedOut));
// …and a host that comes back while parked still wins ("Try again" isn't required).
assert_eq!(w.tick(true).outcome, Some(WakeOutcome::Online));
// Retry replays the identical wait.
w.restart();
assert_eq!(w.seconds(), 0);
assert!(w.tick(false).send_packet);
}
/// The argv every door spawns through. A one-off profile rides the flag; a host BINDING
/// deliberately doesn't — the session resolves it with the same helper, so passing it
/// would be a second source of truth.
#[test]
fn session_args_are_assembled_in_one_place() {
let h = host(
"Desk",
"192.168.1.50",
"11111111-2222-4333-8444-555555555555",
&"a".repeat(64),
);
let mut plan = ConnectPlan {
host: HostTarget::from(&h),
launch: Some("steam:570".into()),
profile: None,
profile_override: None,
settings: Settings {
fullscreen_on_stream: false,
..Default::default()
},
wake: true,
connect_timeout_secs: None,
tofu: false,
clipboard: false,
};
assert_eq!(
plan.session_args(),
vec![
"--connect",
"192.168.1.50:9777",
"--fp",
&"a".repeat(64),
"--launch",
"steam:570"
]
);
plan.profile_override = Some("aaaaaaaaaaaa".into());
plan.connect_timeout_secs = Some(185);
plan.settings.fullscreen_on_stream = true;
let args = plan.session_args();
assert!(args.windows(2).any(|w| w == ["--profile", "aaaaaaaaaaaa"]));
assert!(args.windows(2).any(|w| w == ["--connect-timeout", "185"]));
assert!(args.contains(&"--fullscreen".to_string()));
// "Connect with ▸ Default settings" on a bound host is an EMPTY override, which is
// not the same as no override — it has to survive as a flag.
plan.profile_override = Some(String::new());
let args = plan.session_args();
let i = args.iter().position(|a| a == "--profile").unwrap();
assert_eq!(args[i + 1], "");
}
/// The §3 security rules, in the layer that owns them: an unknown host is a prompt, a
/// contradicted pin is a refusal, an unhonorable profile is a refusal, and an ambiguous
/// reference is never guessed at.
#[test]
fn link_plans_refuse_rather_than_degrade() {
let fp = "a".repeat(64);
let known = KnownHosts {
hosts: vec![
host(
"Desk",
"192.168.1.50",
"11111111-2222-4333-8444-555555555555",
&fp,
),
host(
"Couch",
"192.168.1.60",
"22222222-3333-4444-8555-666666666666",
"",
),
host(
"Couch",
"192.168.1.61",
"33333333-4444-4555-8666-777777777777",
"",
),
],
};
// Pure inputs — the test never touches the config directory.
let catalog = ProfilesFile::default();
let base = Settings::default();
let plan =
|url: &str| plan_from_link(&deeplink::parse(url).unwrap(), &known, &catalog, &base);
// A known, pinned host with a matching (or absent) fp: a plain connect.
let out = plan("punktfunk://connect/Desk").unwrap();
match out {
PlanOutcome::Connect(p) => {
assert_eq!(p.host.addr, "192.168.1.50");
assert_eq!(p.profile_override, None);
assert!(p.host.fp_hex.is_some());
}
other => panic!("expected a connect, got {other:?}"),
}
// A lying/stale fingerprint never connects, and says which host it was about.
assert_eq!(
plan(&format!("punktfunk://connect/Desk?fp={}", "b".repeat(64))),
Err(PlanError::PinConflict {
host: "Desk".into()
})
);
// Ambiguity is reported, never resolved by picking the first.
assert_eq!(
plan("punktfunk://connect/Couch"),
Err(PlanError::AmbiguousHost("Couch".into()))
);
assert_eq!(
plan("punktfunk://connect/00000000-0000-4000-8000-000000000000"),
Err(PlanError::UnresolvableHost(
"00000000-0000-4000-8000-000000000000".into()
))
);
// A profile the catalog can't honor refuses BEFORE anything is dialled.
assert_eq!(
plan("punktfunk://connect/Desk?profile=NoSuchProfile"),
Err(PlanError::UnknownProfile("NoSuchProfile".into()))
);
// An unknown address is a confirmation sheet, never an auto-connect — and it carries
// the claimed name and the expected pin so the first connect is verified, not TOFU.
match plan(&format!(
"punktfunk://connect/10.0.0.9:7000?name=Studio&fp={fp}"
))
.unwrap()
{
PlanOutcome::ConfirmUnknown(u) => assert_eq!(
*u,
UnknownHost {
addr: "10.0.0.9".into(),
port: 7000,
name: Some("Studio".into()),
fp: Some(fp.clone()),
launch: None,
profile: None,
}
),
other => panic!("expected a confirmation, got {other:?}"),
}
// A saved host we never pinned is the same case: known ≠ trusted.
match plan("punktfunk://connect/192.168.1.60").unwrap() {
PlanOutcome::ConfirmUnknown(u) => {
assert_eq!(u.addr, "192.168.1.60");
assert_eq!(u.name.as_deref(), Some("Couch"));
}
other => panic!("expected a confirmation, got {other:?}"),
}
// Routes that parse but aren't implemented here are a notice, not a silent drop.
assert!(matches!(
plan("punktfunk://wake/Desk").unwrap(),
PlanOutcome::Unsupported(Route::Wake)
));
}
/// The spec is the whole of what a session needs, and it round-trips — a field lost here
/// is a setting the stream silently doesn't get.
#[test]
fn resolved_spec_round_trips() {
let spec = ResolvedSpec {
settings: Settings {
width: 2560,
height: 1440,
bitrate_kbps: 55000,
codec: "av1".into(),
present_priority: "smooth".into(),
smooth_buffer: 2,
vsync: false,
allow_vrr: false,
..Default::default()
},
clipboard: true,
profile: Some("Work".into()),
};
let json = serde_json::to_string(&spec).unwrap();
assert_eq!(serde_json::from_str::<ResolvedSpec>(&json).unwrap(), spec);
// A spec without a profile is the defaults, and the key is simply absent.
let plain = ResolvedSpec {
profile: None,
..spec.clone()
};
let json = serde_json::to_string(&plain).unwrap();
assert!(!json.contains("profile"));
assert_eq!(serde_json::from_str::<ResolvedSpec>(&json).unwrap(), plain);
}
/// A plan's spec carries the settings the plan resolved — including the profile's name for
/// the overlay, and the host's clipboard decision the renderer no longer looks up.
#[test]
fn plan_spec_carries_what_the_session_may_not_re_derive() {
let h = KnownHost {
name: "Desk".into(),
addr: "192.168.1.50".into(),
fp_hex: "a".repeat(64),
clipboard_sync: true,
profile_id: Some("aaaaaaaaaaaa".into()),
..Default::default()
};
let catalog = ProfilesFile {
version: 1,
profiles: vec![crate::profiles::StreamProfile {
id: "aaaaaaaaaaaa".into(),
name: "Game".into(),
overrides: crate::profiles::SettingsOverlay {
bitrate_kbps: Some(80000),
..Default::default()
},
..crate::profiles::StreamProfile::new("")
}],
};
let plan = ConnectPlan::resolve(&h, None, None, &catalog, &Settings::default());
let spec = plan.spec(plan.clipboard);
assert_eq!(spec.settings.bitrate_kbps, 80000, "the overlay is baked in");
assert_eq!(spec.profile.as_deref(), Some("Game"));
assert!(spec.clipboard, "the host's decision, resolved once");
}
/// The stdout contract, parsed once for every front-end.
#[test]
fn session_contract_lines() {
assert_eq!(
parse_session_line(r#"{"ready":true}"#),
Some(SessionEvent::Ready)
);
assert_eq!(
parse_session_line(r#"{"error":"no route","trust_rejected":false}"#),
Some(SessionEvent::Error {
msg: "no route".into(),
trust_rejected: false
})
);
assert_eq!(
parse_session_line(r#"{"error":"pin","trust_rejected":true}"#),
Some(SessionEvent::Error {
msg: "pin".into(),
trust_rejected: true
})
);
assert_eq!(
parse_session_line(r#"{"ended":"Host ended the session"}"#),
Some(SessionEvent::Ended("Host ended the session".into()))
);
// The window report the spawner persists on the session's behalf.
assert_eq!(
parse_session_line(r#"{"window":{"w":1600,"h":900}}"#),
Some(SessionEvent::Window { w: 1600, h: 900 })
);
// A half-formed window line is not an event — persisting half a size would be worse
// than ignoring it.
assert_eq!(parse_session_line(r#"{"window":{"w":1600}}"#), None);
// Stats lines and stray output are never events.
assert_eq!(parse_session_line("stats: 1280×800@60 · 60 fps"), None);
assert_eq!(parse_session_line(""), None);
assert_eq!(parse_session_line(r#"{"other":1}"#), None);
}
}