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>
108 lines
4.6 KiB
Rust
108 lines
4.6 KiB
Rust
//! 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`). 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 pf_client_core::orchestrate::{self, ConnectPlan, HostTarget, SessionEvent};
|
|
use pf_client_core::trust::Settings;
|
|
|
|
/// Spawn tunables beyond a plain connect.
|
|
#[derive(Debug, Default)]
|
|
pub struct SpawnOpts {
|
|
/// Handshake budget override (`--connect-timeout`) — the request-access flow passes
|
|
/// ~185 s because the host PARKS the connection until the operator approves.
|
|
pub connect_timeout_secs: Option<u64>,
|
|
/// Persist the host as *paired* once the child reports ready (request-access: the
|
|
/// operator's approval IS the pairing). Plain TOFU persists unpaired.
|
|
pub persist_paired: bool,
|
|
/// A cancel handle to arm (request-access's waiting dialog): killing the child is
|
|
/// the only abort a parked connect has.
|
|
pub cancel: Option<CancelHandle>,
|
|
}
|
|
|
|
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
|
|
/// rather than the store — the app persists it once the child reports ready (the child
|
|
/// connects pinned to it, so ready proves the host really holds that identity).
|
|
///
|
|
/// The caller has already taken `busy`; [`AppMsg::SessionExited`] releases it. `Err` =
|
|
/// the spawn itself failed (binary missing?) — surfaced as a connect error.
|
|
pub fn spawn_session(
|
|
sender: relm4::Sender<AppMsg>,
|
|
req: ConnectRequest,
|
|
fp_hex: String,
|
|
tofu: bool,
|
|
fullscreen_on_stream: bool,
|
|
opts: SpawnOpts,
|
|
) -> Result<(), String> {
|
|
// 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;
|
|
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(),
|
|
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.clone(),
|
|
code,
|
|
error: error.take(),
|
|
ended: ended.take(),
|
|
tofu,
|
|
});
|
|
}
|
|
})?;
|
|
Ok(())
|
|
}
|