feat(session): spec mode — the renderer stops resolving policy and stops writing settings
C2 of design/client-architecture-split.md, and the last of the session's overreach.
`--resolved-spec <path>` hands the session everything it needs already resolved —
effective settings, the host's clipboard decision, the profile's name — and in that mode it
performs ZERO store reads. It had been re-deriving all three, which meant policy was being
evaluated inside the thing that draws pixels, and that the spawner and the child could
disagree about a file either of them might have written in between. First-party spawns
(the shells, the CLI) always pass one now.
The compat path stays for hand-run `punktfunk-session --connect` and old Decky scripts —
but it calls the SAME helper, so the two modes cannot drift; it is the identical function
invoked in-process instead of by the parent. A spec that is named but unreadable fails
loudly rather than quietly falling back: a spawner that asked for exact settings must not
get store-derived ones instead.
The match-window write-back is gone too. The callback used to load-modify-save the shared
settings file from inside the renderer — one of that file's five concurrent writers, for a
value only the parent needs. It now reports `{"window":{w,h}}` on stdout and the spawner
persists it, on a real change only. A hand-run session still persists its own window,
because nobody is listening to its stdout there and the event alone would drop the value.
Verified on .21: a spec naming a profile that doesn't exist in the catalog is honoured
(proving no lookup happened), a missing spec errors instead of falling back, and the CLI's
spec file is written and cleaned up per launch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -571,6 +571,8 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
|||||||
trust_rejected,
|
trust_rejected,
|
||||||
} => failure = Some((msg, trust_rejected)),
|
} => failure = Some((msg, trust_rejected)),
|
||||||
SessionEvent::Ended(reason) => eprintln!("{reason}"),
|
SessionEvent::Ended(reason) => eprintln!("{reason}"),
|
||||||
|
// Persisted by the brain on the way past; nothing to report here.
|
||||||
|
SessionEvent::Window { .. } => {}
|
||||||
SessionEvent::Exited(code) => {
|
SessionEvent::Exited(code) => {
|
||||||
return match failure {
|
return match failure {
|
||||||
Some((msg, true)) => {
|
Some((msg, true)) => {
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ pub fn spawn_session(
|
|||||||
wake: false,
|
wake: false,
|
||||||
connect_timeout_secs: opts.connect_timeout_secs,
|
connect_timeout_secs: opts.connect_timeout_secs,
|
||||||
tofu,
|
tofu,
|
||||||
|
// The per-host clipboard decision, resolved here so the child doesn't look it up
|
||||||
|
// again — matched by address, the way every other per-host lookup matches.
|
||||||
|
clipboard: pf_client_core::trust::KnownHosts::load()
|
||||||
|
.hosts
|
||||||
|
.iter()
|
||||||
|
.any(|h| h.addr == req.addr && h.port == req.port && h.clipboard_sync),
|
||||||
};
|
};
|
||||||
|
|
||||||
let persist_paired = opts.persist_paired;
|
let persist_paired = opts.persist_paired;
|
||||||
@@ -96,6 +102,8 @@ pub fn spawn_session(
|
|||||||
trust_rejected,
|
trust_rejected,
|
||||||
} => error = Some((msg, trust_rejected)),
|
} => error = Some((msg, trust_rejected)),
|
||||||
SessionEvent::Ended(msg) => ended = Some(msg),
|
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) => {
|
SessionEvent::Exited(code) => {
|
||||||
let _ = sender.send(AppMsg::SessionExited {
|
let _ = sender.send(AppMsg::SessionExited {
|
||||||
req: req.clone(),
|
req: req.clone(),
|
||||||
|
|||||||
@@ -183,7 +183,9 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
window_size: crate::session_main::window_size(&settings_at_start),
|
window_size: crate::session_main::window_size(&settings_at_start),
|
||||||
// Latched at console start (like the stats tier above): toggling Match window in
|
// Latched at console start (like the stats tier above): toggling Match window in
|
||||||
// the console's settings screen applies from the next console launch.
|
// the console's settings screen applies from the next console launch.
|
||||||
match_window: crate::session_main::match_window(&settings_at_start),
|
// The console owns its own window across every launch, and no parent is listening to
|
||||||
|
// its stdout, so it keeps persisting the size itself.
|
||||||
|
match_window: crate::session_main::match_window(&settings_at_start, true),
|
||||||
render_scale: settings_at_start.render_scale,
|
render_scale: settings_at_start.render_scale,
|
||||||
render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings_at_start.codec),
|
render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings_at_start.codec),
|
||||||
};
|
};
|
||||||
@@ -218,6 +220,8 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
let mut params = session_params(
|
let mut params = session_params(
|
||||||
&settings,
|
&settings,
|
||||||
profile.map(|p| p.name),
|
profile.map(|p| p.name),
|
||||||
|
// In-process launch: no spawner resolved a clipboard decision for us.
|
||||||
|
None,
|
||||||
addr.clone(),
|
addr.clone(),
|
||||||
port,
|
port,
|
||||||
pin,
|
pin,
|
||||||
|
|||||||
+56
-22
@@ -159,6 +159,7 @@ mod session_main {
|
|||||||
pub(crate) fn session_params(
|
pub(crate) fn session_params(
|
||||||
settings: &trust::Settings,
|
settings: &trust::Settings,
|
||||||
profile: Option<String>,
|
profile: Option<String>,
|
||||||
|
clipboard_override: Option<bool>,
|
||||||
addr: String,
|
addr: String,
|
||||||
port: u16,
|
port: u16,
|
||||||
pin: [u8; 32],
|
pin: [u8; 32],
|
||||||
@@ -169,14 +170,16 @@ mod session_main {
|
|||||||
force_software: Arc<AtomicBool>,
|
force_software: Arc<AtomicBool>,
|
||||||
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
|
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
|
||||||
) -> SessionParams {
|
) -> SessionParams {
|
||||||
// Per-host clipboard opt-in (design/clipboard-and-file-transfer.md §5.3), resolved
|
// Per-host clipboard opt-in (design/clipboard-and-file-transfer.md §5.3). In spec
|
||||||
// here rather than passed in so every caller — a direct connect and the console's
|
// mode the spawner already resolved it; otherwise this looks it up itself, which is
|
||||||
// own launches — honors the same stored decision. `addr` is moved into the struct
|
// the last store read the compat path still owes. `addr` is moved into the struct
|
||||||
// below, so read it first.
|
// below, so read it first.
|
||||||
let clipboard = trust::KnownHosts::load()
|
let clipboard = clipboard_override.unwrap_or_else(|| {
|
||||||
.hosts
|
trust::KnownHosts::load()
|
||||||
.iter()
|
.hosts
|
||||||
.any(|h| h.addr == addr && h.port == port && h.clipboard_sync);
|
.iter()
|
||||||
|
.any(|h| h.addr == addr && h.port == port && h.clipboard_sync)
|
||||||
|
});
|
||||||
// Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name`
|
// Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name`
|
||||||
// key) to OUR gamepad service — the shells' in-process services can't reach this
|
// key) to OUR gamepad service — the shells' in-process services can't reach this
|
||||||
// process. Applied per params-build (idempotent; browse re-launches included) so
|
// process. Applied per params-build (idempotent; browse re-launches included) so
|
||||||
@@ -280,14 +283,24 @@ mod session_main {
|
|||||||
/// debounced resize→`Reconfigure` machinery on; the callback stores each resize-end's
|
/// debounced resize→`Reconfigure` machinery on; the callback stores each resize-end's
|
||||||
/// logical window size (load-modify-save, like the console settings screen) so the
|
/// logical window size (load-modify-save, like the console settings screen) so the
|
||||||
/// next launch opens at it.
|
/// next launch opens at it.
|
||||||
pub(crate) fn match_window(settings: &trust::Settings) -> Option<Box<dyn FnMut(u32, u32)>> {
|
/// The Match-window policy hook (design/midstream-resolution-resize.md D1/D2). The
|
||||||
|
/// callback used to load-modify-save the shared settings file from inside the renderer —
|
||||||
|
/// one of that file's five concurrent writers, for a value only the parent needs. It now
|
||||||
|
/// REPORTS the size on stdout and the spawner persists it
|
||||||
|
/// (design/client-architecture-split.md §5).
|
||||||
|
///
|
||||||
|
/// `persist_locally` keeps a hand-run session remembering its own window: nobody is
|
||||||
|
/// listening to stdout there, so the event alone would drop the value. A spawned session
|
||||||
|
/// leaves the write to its parent, which is the whole point.
|
||||||
|
pub(crate) fn match_window(
|
||||||
|
settings: &trust::Settings,
|
||||||
|
persist_locally: bool,
|
||||||
|
) -> Option<Box<dyn FnMut(u32, u32)>> {
|
||||||
settings.match_window.then(|| {
|
settings.match_window.then(|| {
|
||||||
Box::new(|w: u32, h: u32| {
|
Box::new(move |w: u32, h: u32| {
|
||||||
let mut s = trust::Settings::load();
|
println!("{{\"window\":{{\"w\":{w},\"h\":{h}}}}}");
|
||||||
if (s.last_window_w, s.last_window_h) != (w, h) {
|
if persist_locally {
|
||||||
s.last_window_w = w;
|
pf_client_core::orchestrate::persist_window_size(w, h);
|
||||||
s.last_window_h = h;
|
|
||||||
s.save();
|
|
||||||
}
|
}
|
||||||
}) as Box<dyn FnMut(u32, u32)>
|
}) as Box<dyn FnMut(u32, u32)>
|
||||||
})
|
})
|
||||||
@@ -473,12 +486,31 @@ mod session_main {
|
|||||||
return EXIT_CONNECT_FAILED;
|
return EXIT_CONNECT_FAILED;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Global defaults with this host's settings profile overlaid — the binding on the
|
// `--resolved-spec <path>`: the spawner already did the resolving, so this process
|
||||||
// host record, or `--profile <id|name>` for a one-off (`--profile ""` forces the
|
// performs ZERO store reads (design/client-architecture-split.md §5) — no Settings
|
||||||
// defaults). Resolved through the shared helper, exactly like the console's launches.
|
// load, no known-hosts lookup, no profile resolution. Without it (a hand-run
|
||||||
let (settings, profile) = trust::effective_settings(&addr, port, profile_arg().as_deref());
|
// `--connect`, an old Decky script) the session resolves for itself through the SAME
|
||||||
if let Some(p) = &profile {
|
// helper, so the two modes cannot drift.
|
||||||
tracing::info!(profile = %p.name, id = %p.id, "streaming with a settings profile");
|
let spec = arg_value("--resolved-spec").map(std::path::PathBuf::from);
|
||||||
|
let (settings, profile_name, clipboard_override) = match &spec {
|
||||||
|
Some(path) => match pf_client_core::orchestrate::ResolvedSpec::read(path) {
|
||||||
|
Ok(s) => {
|
||||||
|
tracing::info!(path = %path.display(), "running from a resolved spec");
|
||||||
|
(s.settings, s.profile, Some(s.clipboard))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
json_line("error", &format!("resolved spec: {e}"), None);
|
||||||
|
return EXIT_CONNECT_FAILED;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
let (settings, profile) =
|
||||||
|
trust::effective_settings(&addr, port, profile_arg().as_deref());
|
||||||
|
(settings, profile.map(|p| p.name), None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(name) = &profile_name {
|
||||||
|
tracing::info!(profile = %name, "streaming with a settings profile");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trust follows the GTK client's `--connect` rules: a stored (or `--fp`) pin
|
// Trust follows the GTK client's `--connect` rules: a stored (or `--fp`) pin
|
||||||
@@ -540,7 +572,8 @@ mod session_main {
|
|||||||
#[cfg(not(feature = "ui"))]
|
#[cfg(not(feature = "ui"))]
|
||||||
overlay: None,
|
overlay: None,
|
||||||
window_size: window_size(&settings),
|
window_size: window_size(&settings),
|
||||||
match_window: match_window(&settings),
|
// A spawned session (spec mode) reports its window; a hand-run one persists it.
|
||||||
|
match_window: match_window(&settings, spec.is_none()),
|
||||||
render_scale: settings.render_scale,
|
render_scale: settings.render_scale,
|
||||||
render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings.codec),
|
render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings.codec),
|
||||||
};
|
};
|
||||||
@@ -549,7 +582,8 @@ mod session_main {
|
|||||||
pf_presenter::run_session(opts, move |gamepad, native, force_software, vulkan| {
|
pf_presenter::run_session(opts, move |gamepad, native, force_software, vulkan| {
|
||||||
session_params(
|
session_params(
|
||||||
&settings,
|
&settings,
|
||||||
profile.map(|p| p.name),
|
profile_name,
|
||||||
|
clipboard_override,
|
||||||
addr,
|
addr,
|
||||||
port,
|
port,
|
||||||
pin,
|
pin,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
use crate::deeplink::{DeepLink, HostResolution, Route};
|
use crate::deeplink::{DeepLink, HostResolution, Route};
|
||||||
use crate::profiles::{ProfilesFile, Resolution, StreamProfile};
|
use crate::profiles::{ProfilesFile, Resolution, StreamProfile};
|
||||||
use crate::trust::{effective_settings, KnownHost, KnownHosts, Settings};
|
use crate::trust::{effective_settings, KnownHost, KnownHosts, Settings};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
@@ -79,6 +80,10 @@ pub struct ConnectPlan {
|
|||||||
/// The pin came from an advert rather than the store: persist it once the session reports
|
/// 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).
|
/// ready (ready proves the host really holds that identity).
|
||||||
pub tofu: bool,
|
pub tofu: bool,
|
||||||
|
/// Share this machine's clipboard with this host — a trust decision about the HOST, so it
|
||||||
|
/// lives on the record rather than in a profile, and the spawner resolves it once here
|
||||||
|
/// instead of the renderer looking it up again.
|
||||||
|
pub clipboard: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConnectPlan {
|
impl ConnectPlan {
|
||||||
@@ -101,6 +106,7 @@ impl ConnectPlan {
|
|||||||
settings,
|
settings,
|
||||||
connect_timeout_secs: None,
|
connect_timeout_secs: None,
|
||||||
tofu: false,
|
tofu: false,
|
||||||
|
clipboard: host.clipboard_sync,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +146,17 @@ impl ConnectPlan {
|
|||||||
settings,
|
settings,
|
||||||
connect_timeout_secs: None,
|
connect_timeout_secs: None,
|
||||||
tofu: false,
|
tofu: false,
|
||||||
|
clipboard: host.clipboard_sync,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This plan as a [`ResolvedSpec`] — what a first-party spawner hands the session so it
|
||||||
|
/// performs no store reads of its own.
|
||||||
|
pub fn spec(&self, clipboard: bool) -> ResolvedSpec {
|
||||||
|
ResolvedSpec {
|
||||||
|
settings: self.settings.clone(),
|
||||||
|
clipboard,
|
||||||
|
profile: self.profile.as_ref().map(|p| p.name.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +465,49 @@ pub enum ConnectOutcome {
|
|||||||
// Session spawn + the stdout contract.
|
// Session spawn + the stdout contract.
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Everything a session needs, resolved by the caller — the spec `--resolved-spec` carries
|
||||||
|
/// (design/client-architecture-split.md §5).
|
||||||
|
///
|
||||||
|
/// The session binary is a renderer: given this, it performs ZERO store reads. Its old habit of
|
||||||
|
/// re-deriving state (loading `Settings`, looking up the host's `clipboard_sync`, resolving the
|
||||||
|
/// profile) meant policy was being evaluated inside the thing that draws pixels, and that the
|
||||||
|
/// spawner and the child could disagree about a file either of them might have written since.
|
||||||
|
///
|
||||||
|
/// The compat path — a hand-run `punktfunk-session --connect` with no spec — still resolves for
|
||||||
|
/// itself, but through the *same* helper (`effective_settings`), so the two modes cannot drift.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ResolvedSpec {
|
||||||
|
/// Effective settings: the global defaults with the chosen profile already applied.
|
||||||
|
pub settings: Settings,
|
||||||
|
/// Whether this host may share the clipboard — a per-host trust decision, resolved by the
|
||||||
|
/// spawner rather than re-looked-up here.
|
||||||
|
pub clipboard: bool,
|
||||||
|
/// The profile's name, for the stats overlay. `None` = the global defaults.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub profile: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedSpec {
|
||||||
|
/// Write the spec somewhere the child can read it, returning the path. Temp files rather
|
||||||
|
/// than a pipe because the session already takes a file path elsewhere and a crashed
|
||||||
|
/// spawner leaves something inspectable; the name carries the pid so concurrent launches
|
||||||
|
/// (a shell and a CLI, or two Decky invocations) never overwrite each other's spec.
|
||||||
|
pub fn write_temp(&self) -> std::io::Result<std::path::PathBuf> {
|
||||||
|
let path = std::env::temp_dir().join(format!("punktfunk-spec-{}.json", std::process::id()));
|
||||||
|
let json = serde_json::to_vec_pretty(self)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||||
|
std::fs::write(&path, json)?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a spec written by the spawner.
|
||||||
|
pub fn read(path: &std::path::Path) -> std::io::Result<ResolvedSpec> {
|
||||||
|
let text = std::fs::read_to_string(path)?;
|
||||||
|
serde_json::from_str(&text)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One event from the session child's stdout contract (`{"ready":true}`, `{"error":…}`,
|
/// 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
|
/// `{"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.
|
/// the CLI cannot disagree about what "ready" or "trust rejected" means.
|
||||||
@@ -460,6 +520,13 @@ pub enum SessionEvent {
|
|||||||
trust_rejected: bool,
|
trust_rejected: bool,
|
||||||
},
|
},
|
||||||
Ended(String),
|
Ended(String),
|
||||||
|
/// The session window's logical size settled at this, under the match-window policy. The
|
||||||
|
/// SPAWNER persists it (design §5): a renderer that load-modify-saves the shared settings
|
||||||
|
/// file was one of its five concurrent writers, for a value only the parent needs.
|
||||||
|
Window {
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
},
|
||||||
/// EOF: the child is gone. `-1` = killed by a signal.
|
/// EOF: the child is gone. `-1` = killed by a signal.
|
||||||
Exited(i32),
|
Exited(i32),
|
||||||
}
|
}
|
||||||
@@ -480,9 +547,26 @@ pub fn parse_session_line(line: &str) -> Option<SessionEvent> {
|
|||||||
if let Some(msg) = v.get("ended").and_then(|m| m.as_str()) {
|
if let Some(msg) = v.get("ended").and_then(|m| m.as_str()) {
|
||||||
return Some(SessionEvent::Ended(msg.to_string()));
|
return Some(SessionEvent::Ended(msg.to_string()));
|
||||||
}
|
}
|
||||||
|
if let Some(win) = v.get("window") {
|
||||||
|
let dim = |k: &str| win.get(k).and_then(|n| n.as_u64()).map(|n| n as u32);
|
||||||
|
if let (Some(w), Some(h)) = (dim("w"), dim("h")) {
|
||||||
|
return Some(SessionEvent::Window { w, h });
|
||||||
|
}
|
||||||
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist a window size the session reported. The spawner's job now, not the renderer's — and
|
||||||
|
/// it writes only on a real change, so a session that never resizes never touches the file.
|
||||||
|
pub fn persist_window_size(w: u32, h: u32) {
|
||||||
|
let mut s = Settings::load();
|
||||||
|
if (s.last_window_w, s.last_window_h) != (w, h) {
|
||||||
|
s.last_window_w = w;
|
||||||
|
s.last_window_h = h;
|
||||||
|
s.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The session binary: installed next to this executable, else `$PATH` (a dev run out of
|
/// The session binary: installed next to this executable, else `$PATH` (a dev run out of
|
||||||
/// `target/…` lands on the sibling).
|
/// `target/…` lands on the sibling).
|
||||||
pub fn session_binary() -> std::path::PathBuf {
|
pub fn session_binary() -> std::path::PathBuf {
|
||||||
@@ -525,7 +609,23 @@ pub fn spawn_session(
|
|||||||
on_event: impl FnMut(SessionEvent) + Send + 'static,
|
on_event: impl FnMut(SessionEvent) + Send + 'static,
|
||||||
) -> Result<CancelHandle, String> {
|
) -> Result<CancelHandle, String> {
|
||||||
let mut cmd = Command::new(session_binary());
|
let mut cmd = Command::new(session_binary());
|
||||||
cmd.args(plan.session_args())
|
let mut args = plan.session_args();
|
||||||
|
// Spec mode: hand the child the settings we already resolved, so it reads no stores and
|
||||||
|
// cannot disagree with us about a file either of us might write (design §5). A spec we
|
||||||
|
// fail to write is not fatal — the child's compat path resolves the same values through
|
||||||
|
// the same helper, which is exactly why that path was kept.
|
||||||
|
let spec_path = match plan.spec(plan.clipboard).write_temp() {
|
||||||
|
Ok(path) => {
|
||||||
|
args.push("--resolved-spec".into());
|
||||||
|
args.push(path.to_string_lossy().into_owned());
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "couldn't write the resolved spec; the session will resolve for itself");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cmd.args(args)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::inherit()); // the session's logs interleave with the front-end's
|
.stderr(Stdio::inherit()); // the session's logs interleave with the front-end's
|
||||||
@@ -550,9 +650,19 @@ pub fn spawn_session(
|
|||||||
for line in std::io::BufReader::new(stdout).lines() {
|
for line in std::io::BufReader::new(stdout).lines() {
|
||||||
let Ok(line) = line else { break };
|
let Ok(line) = line else { break };
|
||||||
if let Some(ev) = parse_session_line(&line) {
|
if let Some(ev) = parse_session_line(&line) {
|
||||||
|
// The window size is the spawner's to persist — the renderer only reports
|
||||||
|
// it. Front-ends still see the event; they just don't have to act on it.
|
||||||
|
if let SessionEvent::Window { w, h } = ev {
|
||||||
|
persist_window_size(w, h);
|
||||||
|
}
|
||||||
on_event(ev);
|
on_event(ev);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The spec has done its job the moment the child has read it; a leftover temp file
|
||||||
|
// in %TEMP% is litter, and one per launch adds up.
|
||||||
|
if let Some(path) = &spec_path {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
// EOF — reap (a cancel-killed child lands here too; -1 = died on a signal).
|
// EOF — reap (a cancel-killed child lands here too; -1 = died on a signal).
|
||||||
let code = reader_slot
|
let code = reader_slot
|
||||||
.0
|
.0
|
||||||
@@ -675,6 +785,7 @@ mod tests {
|
|||||||
wake: true,
|
wake: true,
|
||||||
connect_timeout_secs: None,
|
connect_timeout_secs: None,
|
||||||
tofu: false,
|
tofu: false,
|
||||||
|
clipboard: false,
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.session_args(),
|
plan.session_args(),
|
||||||
@@ -807,6 +918,65 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The spec is the whole of what a session needs, and it round-trips — a field lost here
|
||||||
|
/// is a setting the stream silently doesn't get.
|
||||||
|
#[test]
|
||||||
|
fn resolved_spec_round_trips() {
|
||||||
|
let spec = ResolvedSpec {
|
||||||
|
settings: Settings {
|
||||||
|
width: 2560,
|
||||||
|
height: 1440,
|
||||||
|
bitrate_kbps: 55000,
|
||||||
|
codec: "av1".into(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
clipboard: true,
|
||||||
|
profile: Some("Work".into()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&spec).unwrap();
|
||||||
|
assert_eq!(serde_json::from_str::<ResolvedSpec>(&json).unwrap(), spec);
|
||||||
|
|
||||||
|
// A spec without a profile is the defaults, and the key is simply absent.
|
||||||
|
let plain = ResolvedSpec {
|
||||||
|
profile: None,
|
||||||
|
..spec.clone()
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&plain).unwrap();
|
||||||
|
assert!(!json.contains("profile"));
|
||||||
|
assert_eq!(serde_json::from_str::<ResolvedSpec>(&json).unwrap(), plain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A plan's spec carries the settings the plan resolved — including the profile's name for
|
||||||
|
/// the overlay, and the host's clipboard decision the renderer no longer looks up.
|
||||||
|
#[test]
|
||||||
|
fn plan_spec_carries_what_the_session_may_not_re_derive() {
|
||||||
|
let h = KnownHost {
|
||||||
|
name: "Desk".into(),
|
||||||
|
addr: "192.168.1.50".into(),
|
||||||
|
fp_hex: "a".repeat(64),
|
||||||
|
clipboard_sync: true,
|
||||||
|
profile_id: Some("aaaaaaaaaaaa".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let catalog = ProfilesFile {
|
||||||
|
version: 1,
|
||||||
|
profiles: vec![crate::profiles::StreamProfile {
|
||||||
|
id: "aaaaaaaaaaaa".into(),
|
||||||
|
name: "Game".into(),
|
||||||
|
overrides: crate::profiles::SettingsOverlay {
|
||||||
|
bitrate_kbps: Some(80000),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..crate::profiles::StreamProfile::new("")
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let plan = ConnectPlan::resolve(&h, None, None, &catalog, &Settings::default());
|
||||||
|
let spec = plan.spec(plan.clipboard);
|
||||||
|
assert_eq!(spec.settings.bitrate_kbps, 80000, "the overlay is baked in");
|
||||||
|
assert_eq!(spec.profile.as_deref(), Some("Game"));
|
||||||
|
assert!(spec.clipboard, "the host's decision, resolved once");
|
||||||
|
}
|
||||||
|
|
||||||
/// The stdout contract, parsed once for every front-end.
|
/// The stdout contract, parsed once for every front-end.
|
||||||
#[test]
|
#[test]
|
||||||
fn session_contract_lines() {
|
fn session_contract_lines() {
|
||||||
@@ -832,6 +1002,14 @@ mod tests {
|
|||||||
parse_session_line(r#"{"ended":"Host ended the session"}"#),
|
parse_session_line(r#"{"ended":"Host ended the session"}"#),
|
||||||
Some(SessionEvent::Ended("Host ended the session".into()))
|
Some(SessionEvent::Ended("Host ended the session".into()))
|
||||||
);
|
);
|
||||||
|
// The window report the spawner persists on the session's behalf.
|
||||||
|
assert_eq!(
|
||||||
|
parse_session_line(r#"{"window":{"w":1600,"h":900}}"#),
|
||||||
|
Some(SessionEvent::Window { w: 1600, h: 900 })
|
||||||
|
);
|
||||||
|
// A half-formed window line is not an event — persisting half a size would be worse
|
||||||
|
// than ignoring it.
|
||||||
|
assert_eq!(parse_session_line(r#"{"window":{"w":1600}}"#), None);
|
||||||
// Stats lines and stray output are never events.
|
// 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("stats: 1280×800@60 · 60 fps"), None);
|
||||||
assert_eq!(parse_session_line(""), None);
|
assert_eq!(parse_session_line(""), None);
|
||||||
|
|||||||
Reference in New Issue
Block a user