ci / rust-arm64 (pull_request) Failing after 1m43s
ci / web (pull_request) Successful in 1m52s
ci / bun-nix (pull_request) Successful in 28s
ci / docs-site (pull_request) Successful in 3m36s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 2m52s
android / android (pull_request) Successful in 6m23s
- adl_emul.rs adl_malloc: panic-free (a reachable expect in an extern fn is an abort — gate B; the Err arm is unreachable, ADL treats null as failure) - punktfunk-host main.rs: reword the carve-out comments so gate C's textual count stays at its baseline (comments count) - clients/linux: forbid(unsafe_code) -> deny with two named allows — the SDL device-filter clear and the spawn test's HOME scoping are unsafe calls in edition 2024 (caught by the aarch64 leg, the only one with glib)
223 lines
10 KiB
Rust
223 lines
10 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]
|
|
// The crate's one test env mutation (the `HOME` scoping below) — see main.rs's deny note.
|
|
#[allow(unsafe_code)]
|
|
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();
|
|
// SAFETY: the only env-mutating test in this binary (see the doc above — one test, one
|
|
// `HOME`, deliberately not split). Parallel tests may `getenv` concurrently; glibc keeps
|
|
// replaced environ storage alive, and every reader tolerates either value.
|
|
unsafe { 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);
|
|
}
|
|
}
|