Files
punktfunk/clients/linux/src/spawn.rs
T
enricobuehlerandClaude Opus 5 9c5af8d7e1
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 2m50s
ci / rust-arm64 (push) Successful in 2m55s
deb / build-publish-host (push) Successful in 4m16s
deb / build-publish-client-arm64 (push) Successful in 4m56s
apple / swift (push) Successful in 4m41s
flatpak / build-publish (push) Successful in 6m32s
deb / build-publish (push) Successful in 3m44s
android / android (push) Successful in 8m2s
ci / rust (push) Successful in 11m18s
arch / build-publish (push) Successful in 10m24s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m42s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m10s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m1s
apple / screenshots (push) Successful in 20m36s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m40s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m9s
fix(clients): the settings you chose reach the stream again, and four more that never did
Since spec mode (`f32c3aaa`, shipped in 0.22.0) the GTK shell has handed the session
a `--resolved-spec` built from `Settings::default()`. A session running from a spec
performs ZERO store reads by design, so every stream since has run at the defaults:
`bitrate_kbps: 0` reaches the host, which reads it as its 20 Mbps fallback — the
field report this starts from, "my bitrate seems to be stuck at 20 MBPS" on 0.22.3 —
and with it the resolution, refresh, render scale, codec, decoder, HDR, 4:4:4, audio
channels, mic, touch/mouse mode, invert scroll, stats tier, match-window and gamepad
type. Profiles never applied at all: the spec's `profile` was None, and spec mode
ignores `--profile`. The tell in the wild is that the GPU and audio-device pickers
kept working — the session reads those three off disk into env vars before it reads
the spec.

The plan carried a comment explaining that `settings` "carries only what the argv
needs (the fullscreen flag)". That was true when it was written and stopped being
true one commit later, in another file. `ConnectPlan::for_target` answers that shape
of bug: a front-end holding its own request type resolves through the same helper the
session's compat path uses, so there is no hand-built `Settings` left to go stale.
The GTK shell's `fullscreen_on_stream` parameter goes with it — that is a tier-P
field, and a shell-read global was beating a profile that set it. The Windows shell
was never affected (it spawns with no spec) but had the same fullscreen-vs-profile
bug, so it moves onto the resolver too.

The audit that followed found four more settings stored, rendered in two UIs, and
read by nothing:

- `enable_444` never became `VIDEO_CAP_444`. "Full chroma (4:4:4)" is announced in
  the 0.22.0 notes for Linux and Windows and did nothing on either; only Apple
  advertised the bit. The host already gates it on its own policy, its capturer,
  HEVC and a real GPU probe, and answers the resolved chroma in the Welcome before
  we build a decoder — the client only has to ask. There is no desktop decode probe
  (Apple has `Stage444Probe`), but the presenter bails cleanly on a plane format it
  doesn't know and demotes to software, so the downside is a slow decode, not a
  broken one.
- `session_params` picked the HEIGHT fallback off `settings.width == 0`.
- `cli::exec_session` dropped `--profile` from its forward list, so
  `punktfunk --connect … --profile Work` from a script or a Decky wrapper streamed
  with the host's binding instead.
- `ResolvedSpec::write_temp` named the spec by the SPAWNER's pid alone, so a
  cancelled connect and the retry behind it shared one path — and the first child's
  exit deleted the file the second was still starting up to read.

The regression test asserts the plan carries the profile-overlaid bitrate and that
the spec equals it. On the old code both are 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:32:51 +02:00

218 lines
9.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};
/// 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};
/// The plan one card click resolves to. `for_target` does ALL of the resolving — this
/// device's settings with the host's bound profile (or the "Connect with ▸" one-off) overlaid,
/// and the per-host clipboard decision — through the same helpers the session's own compat path
/// uses, so the two cannot disagree.
///
/// Resolving is not optional: `orchestrate::spawn_session` writes `plan.settings` into the
/// child's `--resolved-spec`, and a session running from a spec reads NO stores. This used to
/// build the plan with `..Settings::default()`, on the theory that only the argv read it —
/// which meant every stream since the arch-split ran at the DEFAULTS: the host's fallback
/// bitrate (20 Mbps), the native resolution, `auto` codec, stereo, and no profile.
fn plan_for(req: &ConnectRequest, fp_hex: &str, tofu: bool, opts: &SpawnOpts) -> ConnectPlan {
let mut plan = ConnectPlan::for_target(
HostTarget {
name: req.name.clone(),
addr: req.addr.clone(),
port: req.port,
fp_hex: Some(fp_hex.to_string()),
mac: req.mac.clone(),
id: None,
},
req.launch.as_ref().map(|(id, _)| id.clone()),
// A plain card click carries no one-off: the resolver honors the host's own binding
// (design/client-settings-profiles.md §4.6). Only a "Connect with ▸" pick (or a URL's
// `profile=`) sets one, and it applies to this session alone.
req.profile.clone(),
);
// This shell still runs its own dial-first wake fallback in `app.rs`, so the plan's own
// wake stays off (it becomes real when the GTK connect path moves onto
// `ConnectOrchestrator`, arch-split C0).
plan.wake = false;
plan.connect_timeout_secs = opts.connect_timeout_secs;
plan.tofu = tofu;
plan
}
/// 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).
///
/// Settings are NOT a parameter: the plan resolves them (including the fullscreen policy,
/// which is a profileable field — a shell-read global would override a profile that set it).
///
/// 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,
opts: SpawnOpts,
) -> Result<(), String> {
let plan = plan_for(&req, &fp_hex, tofu, &opts);
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),
// The brain persists the window size; the shell has nothing to do with it.
SessionEvent::Window { .. } => {}
SessionEvent::Exited(code) => {
let _ = sender.send(AppMsg::SessionExited {
req: req.clone(),
code,
error: error.take(),
ended: ended.take(),
tofu,
});
}
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// The plan a card click builds carries THIS DEVICE'S settings, with the host's bound
/// profile overlaid — not `Settings::default()`.
///
/// This is the 0.22.x regression, and it was invisible because the defaults are all
/// plausible: the spec's `bitrate_kbps: 0` means "host default", which the host reads as
/// its 20 Mbps fallback. A stream that ignores every setting looks exactly like a stream
/// that is merely capped. One test, one `HOME` — the stores are read from it, so this
/// deliberately does not split into several that would race over the same env var.
#[test]
fn the_plan_carries_resolved_settings_not_defaults() {
use pf_client_core::profiles::{ProfilesFile, SettingsOverlay, StreamProfile};
use pf_client_core::trust::{KnownHost, KnownHosts, Settings};
let home = std::env::temp_dir().join(format!("pf-spawn-test-{}", std::process::id()));
let cfg = home.join(".config/punktfunk");
std::fs::create_dir_all(&cfg).unwrap();
std::env::set_var("HOME", &home);
// A device whose owner has set a bitrate, and a host bound to a profile that raises it
// further — the two layers the spec has to carry.
let globals = Settings {
bitrate_kbps: 200_000,
width: 2560,
height: 1440,
codec: "av1".into(),
fullscreen_on_stream: false,
..Default::default()
};
globals.save();
let mut catalog = ProfilesFile {
version: 1,
profiles: vec![StreamProfile {
id: "aaaaaaaaaaaa".into(),
name: "Game".into(),
overrides: SettingsOverlay {
bitrate_kbps: Some(750_000),
..Default::default()
},
..StreamProfile::new("")
}],
};
catalog.save().unwrap();
let known = KnownHosts {
hosts: vec![KnownHost {
name: "Desk".into(),
addr: "192.168.1.50".into(),
port: 9777,
fp_hex: "a".repeat(64),
paired: true,
profile_id: Some("aaaaaaaaaaaa".into()),
clipboard_sync: true,
..Default::default()
}],
};
known.save().unwrap();
let req = ConnectRequest {
name: "Desk".into(),
addr: "192.168.1.50".into(),
port: 9777,
fp_hex: Some("a".repeat(64)),
pair_optional: false,
launch: None,
mac: vec![],
profile: None,
};
let opts = SpawnOpts::default();
// A card click: globals, with the BOUND profile's overrides on top.
let plan = plan_for(&req, &"a".repeat(64), false, &opts);
assert_eq!(plan.settings.bitrate_kbps, 750_000, "profile override");
assert_eq!((plan.settings.width, plan.settings.height), (2560, 1440));
assert_eq!(plan.settings.codec, "av1");
assert_eq!(plan.profile.as_ref().map(|p| p.name.as_str()), Some("Game"));
assert!(plan.clipboard, "the host's own opt-in");
// …and the spec the child actually runs from is those same settings.
assert_eq!(plan.spec(plan.clipboard).settings, plan.settings);
// The fullscreen policy rides the argv from the resolved settings, not a shell global.
assert!(!plan.session_args().contains(&"--fullscreen".to_string()));
// "Connect with ▸ Default settings" (`Some("")`) drops back to the globals, and the
// flag still rides so the session agrees about which layer it is on.
let defaults_req = ConnectRequest {
profile: Some(String::new()),
..req.clone()
};
let plan = plan_for(&defaults_req, &"a".repeat(64), false, &opts);
assert_eq!(plan.settings.bitrate_kbps, 200_000, "back to the globals");
assert_eq!(plan.profile, None);
let args = plan.session_args();
let i = args.iter().position(|a| a == "--profile").unwrap();
assert_eq!(args[i + 1], "");
let _ = std::fs::remove_dir_all(&home);
}
}