diff --git a/clients/linux/src/spawn.rs b/clients/linux/src/spawn.rs index 11d2aeb8..2e898d58 100644 --- a/clients/linux/src/spawn.rs +++ b/clients/linux/src/spawn.rs @@ -1,15 +1,20 @@ //! 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. +//! punktfunk-planning `linux-client-rearchitecture.md`). What is left here is the +//! TRANSLATION: a [`ConnectRequest`] becomes a `ConnectPlan`, and the session's typed +//! lifecycle events become the [`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. +//! +//! Spawning, the argv, the stdout contract and the child handle live in +//! `pf_client_core::orchestrate` (design/client-architecture-split.md §3) — the WinUI shell +//! and the coming CLI spawn through the same code, so "what flags does a stream get" and +//! "what does ready mean" have exactly one answer. 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}; +use pf_client_core::orchestrate::{self, ConnectPlan, HostTarget, SessionEvent}; +use pf_client_core::trust::Settings; /// Spawn tunables beyond a plain connect. #[derive(Debug, Default)] @@ -25,55 +30,7 @@ pub struct SpawnOpts { pub cancel: Option, } -/// 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>>); - -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 { - 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() -} +pub use orchestrate::{session_binary, CancelHandle}; /// 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 @@ -90,114 +47,61 @@ pub fn spawn_session( 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); + // The plan a card click resolves to. No `--profile`: a plain click honors the host's own + // binding, which the session resolves through the same helper this shell would have used + // (design/client-settings-profiles.md §4.6) — passing it here would be a second source of + // truth for the same decision. + // + // Two fields are deliberately not the shell's state yet, because nothing in the spawn path + // reads them: `settings` carries only what the argv needs (the fullscreen flag), and `wake` + // is false because this shell still runs its own dial-first wake fallback in `app.rs`. Both + // become real when the GTK connect path moves onto `ConnectOrchestrator` (arch-split C0). + let plan = ConnectPlan { + host: HostTarget { + name: req.name.clone(), + addr: req.addr.clone(), + port: req.port, + fp_hex: Some(fp_hex.clone()), + mac: req.mac.clone(), + id: None, + }, + launch: req.launch.as_ref().map(|(id, _)| id.clone()), + profile: None, + profile_override: None, + settings: Settings { + fullscreen_on_stream, + ..Default::default() + }, + wake: false, + connect_timeout_secs: opts.connect_timeout_secs, + tofu, + }; 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 = 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 (mut error, mut ended) = (None::<(String, bool)>, None::); + orchestrate::spawn_session(&plan, opts.cancel, move |ev| match ev { + SessionEvent::Ready => { + let _ = sender.send(AppMsg::SessionReady { + req: req.clone(), + fp_hex: fp_hex.clone(), + tofu, + persist_paired, + }); + } + SessionEvent::Error { + msg, + trust_rejected, + } => error = Some((msg, trust_rejected)), + SessionEvent::Ended(msg) => ended = Some(msg), + SessionEvent::Exited(code) => { let _ = sender.send(AppMsg::SessionExited { - req, + req: req.clone(), code, - error, - ended, + error: error.take(), + ended: ended.take(), 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()); - } -} diff --git a/crates/pf-client-core/src/lib.rs b/crates/pf-client-core/src/lib.rs index d76889e4..6dd13733 100644 --- a/crates/pf-client-core/src/lib.rs +++ b/crates/pf-client-core/src/lib.rs @@ -31,6 +31,10 @@ pub mod library; // shells, the session and the CLI, held to the Swift/Kotlin ports by a shared vector file. #[cfg(any(target_os = "linux", windows))] pub mod deeplink; +// The brain layer (design/client-architecture-split.md §3): what a connect is, the wake +// state machine every front-end drives, and the session spawn + stdout contract. +#[cfg(any(target_os = "linux", windows))] +pub mod orchestrate; // Client settings profiles: the override catalog + the one connect-time resolver // (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records // the bindings live on. diff --git a/crates/pf-client-core/src/orchestrate.rs b/crates/pf-client-core/src/orchestrate.rs new file mode 100644 index 00000000..f4568cef --- /dev/null +++ b/crates/pf-client-core/src/orchestrate.rs @@ -0,0 +1,840 @@ +//! 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 20–60 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 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, + pub mac: Vec, + pub id: Option, +} + +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, + /// The settings profile this connect resolved with (display + the stats overlay). + pub profile: Option, + /// 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, + /// 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, + /// 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, +} + +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, + } + } + + /// 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, + } + } + + /// 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 { + 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()); + } + 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), + /// 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), + /// 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, + /// 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, + pub launch: Option, + pub profile: Option, +} + +/// 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 { + 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, +} + +#[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 … 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), + /// 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. +// --------------------------------------------------------------------------------------- + +/// 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), + /// 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 { + 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())); + } + None +} + +/// 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>>); + +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, + on_event: impl FnMut(SessionEvent) + Send + 'static, +) -> Result { + let mut cmd = Command::new(session_binary()); + cmd.args(plan.session_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) { + on_event(ev); + } + } + // 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, + }; + 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 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())) + ); + // 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); + } +} diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 4f2eb434..7f5dfca5 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -660,7 +660,7 @@ impl MouseMode { /// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file /// stays readable; parsed with `*Pref::from_name` at connect time. -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Settings { /// Stream mode; `0` = the native size/refresh of the monitor the window is on,