feat(client): the brain layer — one connect plan, one wake machine, one spawn
C0 of design/client-architecture-split.md. Wake-then-connect exists three times today — GTK's `WakeConnect`/`wake_fallback`, the WinUI shell's `wake_and_connect` (whose comment says it "mirrors the Apple HostWaker"), and Apple's `HostWaker` itself — and the deep-link and profile work was about to make it five. `pf-client-core` already held the ingredients (wol, discovery, trust, library, session); what was missing was the layer that composes them, so every front-end composed them itself. `ConnectPlan` is a resolved intent: which host, which pin, which launch id, which profile, the effective settings, whether to wake. One constructor per door — a card click, a CLI verb, a URL — one type out. `ConnectPlan::resolve` is pure (stores in, plan out), which is what lets the URL router be tested without a config directory; `for_host` is the convenience that loads them. `plan_from_link` is where the deep-link security rules actually live, rather than in each shell: a contradicted fingerprint refuses and says which host it was about, an ambiguous name refuses instead of picking the first, a profile the catalog can't honor refuses BEFORE anything is dialled, and an unknown — or known-but-never-pinned — host becomes a confirmation sheet carrying the claimed name and expected pin, so the first connect is verified rather than blind TOFU. Preempting a live session stays with the caller: only the front-end knows one is running. `WakeWait` is Apple's `HostWaker` cadence as a pure step function — packet at 0 s and every 6 s, presence polled every second, 90 s budget, and a PARK (not an error) at the end, because "it didn't wake in 90 s" is usually "give it 10 more". As a step function each front-end drives it from its own loop and they still agree, and the whole cadence is testable without waiting 90 seconds. Session spawn, its argv and its stdout contract move here too, and the GTK shell now spawns through them — so "which flags does a stream get" and "what does ready mean" have one answer for the shells, the console and the coming CLI. `--profile` deliberately does NOT ride a plain card click: the session resolves the host's binding through the same helper, and passing it would be a second source of truth for one decision. Not yet adopted: the two shells' wake paths. That swap changes the most-used path in the product and the handoff wants it verified on glass with a genuinely sleeping host, which this session couldn't do — so the state machine lands, the duplication stays until it can be verified away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+54
-150
@@ -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<CancelHandle>,
|
||||
}
|
||||
|
||||
/// Kills the spawned session child (the request-access Cancel button). Safe to call
|
||||
/// any time; a child that already exited is a no-op.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CancelHandle(Arc<Mutex<Option<Child>>>);
|
||||
|
||||
impl CancelHandle {
|
||||
pub fn kill(&self) {
|
||||
if let Some(child) = self.0.lock().unwrap().as_mut() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One parsed stdout line from the session child's contract.
|
||||
enum ChildEvent {
|
||||
Ready,
|
||||
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<ChildEvent> {
|
||||
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,44 +47,40 @@ 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<String> = None;
|
||||
for line in std::io::BufReader::new(stdout).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
match parse_line(&line) {
|
||||
Some(ChildEvent::Ready) => {
|
||||
let (mut error, mut ended) = (None::<(String, bool)>, None::<String>);
|
||||
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(),
|
||||
@@ -135,69 +88,20 @@ pub fn spawn_session(
|
||||
persist_paired,
|
||||
});
|
||||
}
|
||||
Some(ChildEvent::Error {
|
||||
SessionEvent::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");
|
||||
} => 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<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,
|
||||
}
|
||||
|
||||
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<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());
|
||||
}
|
||||
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.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// 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<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()));
|
||||
}
|
||||
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<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());
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user