diff --git a/Cargo.lock b/Cargo.lock index afa2a2c5..2a2d3bdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2863,6 +2863,7 @@ dependencies = [ "pipewire", "punktfunk-core", "pyrowave-sys", + "rand 0.9.4", "rustls", "sdl3", "serde", diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index d14e9ade..3156a3a1 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -336,11 +336,7 @@ pub fn headless_add_host(target: &str) -> glib::ExitCode { name, addr: addr.clone(), port, - fp_hex: String::new(), - paired: false, - last_used: None, - mac: Vec::new(), - clipboard_sync: false, + ..Default::default() }); } match known.save() { diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index e4894761..3fb04089 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -201,11 +201,16 @@ pub fn run(target: Option<&str>) -> u8 { tracing::info!(%addr, %title, request_access, launch = launch.as_deref().unwrap_or("desktop"), "launching from the console"); - // Settings re-load per launch: the console's own settings screen - // may have changed them since the last stream. - let settings = trust::Settings::load(); + // Settings re-resolve per launch: the console's own settings screen may + // have changed the defaults since the last stream, and the host may carry + // a profile binding. Console (and therefore Decky, which spawns this + // binary) honors bindings with no console-side work — the resolver is the + // same one `--connect` goes through. No one-off here: picking a profile is + // a desktop-shell affordance in v1, pinned cards are the console's. + let (settings, profile) = trust::effective_settings(&addr, port, None); let mut params = session_params( &settings, + profile.map(|p| p.name), addr.clone(), port, pin, @@ -434,11 +439,7 @@ impl ServiceState { name: if name.is_empty() { addr.clone() } else { name }, addr, port, - fp_hex: String::new(), - paired: false, - last_used: None, - mac: Vec::new(), - clipboard_sync: false, + ..Default::default() }); } if let Err(e) = known.save() { diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index 5fc2d1b9..715129be 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -124,6 +124,15 @@ mod session_main { } } + /// `--profile ` — the settings profile this one session runs with, overriding the + /// host's own binding for this launch only (never rebinding it): the shells' "Connect + /// with ▸ X" and a `punktfunk://…&profile=` link both land here. Absent = honor the host's + /// binding; `--profile ""` (or a bare `--profile`) forces the global defaults, which is + /// how "Connect with ▸ Default settings" reaches a bound host. + fn profile_arg() -> Option { + arg_flag("--profile").then(|| arg_value("--profile").unwrap_or_default()) + } + /// The connect budget: 15 s normally; `--connect-timeout SECS` overrides — the /// shell's request-access flow passes ~185 s because the host PARKS the connection /// until the operator clicks Approve. @@ -135,13 +144,20 @@ mod session_main { ) } - /// One session's pump parameters from the Settings store — shared by `--connect` + /// One session's pump parameters from the EFFECTIVE settings — shared by `--connect` /// and every `--browse` launch. Explicit settings, `0` fields resolved to the /// window's display (the GTK client reads the monitor under its window — same /// contract). + /// + /// `settings` is what [`trust::effective_settings`] returned, never a raw + /// `Settings::load()`: both callers resolve the host's profile first, so the two + /// construction sites cannot drift (they historically did — touching one and not the + /// other is a Windows-only build break). `profile` is that profile's name, for the + /// stats overlay's first line. #[allow(clippy::too_many_arguments)] pub(crate) fn session_params( settings: &trust::Settings, + profile: Option, addr: String, port: u16, pin: [u8; 32], @@ -246,6 +262,7 @@ mod session_main { identity, connect_timeout: connect_timeout(), force_software, + profile, } } @@ -431,14 +448,16 @@ mod session_main { } let Some(target) = arg_value("--connect") else { eprintln!( - "usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen]\n\ + "usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--profile REF] [--fullscreen]\n\ \x20 punktfunk-session --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\ \x20 punktfunk-session --pair --connect host[:port] [--name LABEL]\n\ \n\ Streams from a paired punktfunk host in a Vulkan window. --browse opens the\n\ gamepad console instead: bare --browse is the host list (discovery, PIN\n\ pairing, settings, wake-on-LAN); with a target it opens that host's game\n\ - library. --connect never dials a host it has no pinned fingerprint for —\n\ + library. --profile picks a settings profile by id or name for this session\n\ + only (\"\" = the global defaults); without it the host's own profile applies.\n\ + --connect never dials a host it has no pinned fingerprint for —\n\ enrol with --pair (no display needed), in the console, or from the desktop\n\ client." ); @@ -453,7 +472,13 @@ mod session_main { return EXIT_CONNECT_FAILED; } }; - let settings = trust::Settings::load(); + // Global defaults with this host's settings profile overlaid — the binding on the + // host record, or `--profile ` for a one-off (`--profile ""` forces the + // defaults). Resolved through the shared helper, exactly like the console's launches. + let (settings, profile) = trust::effective_settings(&addr, port, profile_arg().as_deref()); + if let Some(p) = &profile { + tracing::info!(profile = %p.name, id = %p.id, "streaming with a settings profile"); + } // Trust follows the GTK client's `--connect` rules: a stored (or `--fp`) pin // connects silently; an unknown host is REFUSED — there is no dialog here, and a @@ -523,6 +548,7 @@ mod session_main { pf_presenter::run_session(opts, move |gamepad, native, force_software, vulkan| { session_params( &settings, + profile.map(|p| p.name), addr, port, pin, diff --git a/clients/windows/src/app/connect.rs b/clients/windows/src/app/connect.rs index d9df2e9f..c83ded56 100644 --- a/clients/windows/src/app/connect.rs +++ b/clients/windows/src/app/connect.rs @@ -272,9 +272,8 @@ fn connect_spawn( port: target.port, fp_hex: fp_hex.clone(), paired: persist_paired, - last_used: None, mac: target.mac.clone(), - clipboard_sync: false, + ..Default::default() }); let _ = k.save(); } @@ -481,10 +480,8 @@ fn wake_and_connect( addr: target.addr.clone(), port: target.port, fp_hex: fp, - paired: false, - last_used: None, mac: target.mac.clone(), - clipboard_sync: false, + ..Default::default() }); let _ = k.save(); } diff --git a/clients/windows/src/app/pair.rs b/clients/windows/src/app/pair.rs index 6852e40c..2a47d320 100644 --- a/clients/windows/src/app/pair.rs +++ b/clients/windows/src/app/pair.rs @@ -57,9 +57,8 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element { port: target3.port, fp_hex: trust::hex(&fp), paired: true, - last_used: None, mac: target3.mac.clone(), - clipboard_sync: false, + ..Default::default() }); let _ = k.save(); connect(&ctx3, &target3, Some(fp), &ss, &st); diff --git a/crates/pf-client-core/Cargo.toml b/crates/pf-client-core/Cargo.toml index 0cb973f7..f8750dec 100644 --- a/crates/pf-client-core/Cargo.toml +++ b/crates/pf-client-core/Cargo.toml @@ -41,6 +41,9 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" tracing = "0.1" +# Stable ids for profiles and host records (profiles.rs) — the OS RNG only, same version the +# workspace already resolves for punktfunk-core. No uuid crate: the v4 layout is four lines. +rand = "0.9" # Gamepads: capture + feedback (full DualSense fidelity — touchpad/motion/triggers/LEDs # need the hidapi driver). Linux links the system SDL3; Windows builds it from source diff --git a/crates/pf-client-core/src/lib.rs b/crates/pf-client-core/src/lib.rs index 5266331b..82375cb4 100644 --- a/crates/pf-client-core/src/lib.rs +++ b/crates/pf-client-core/src/lib.rs @@ -27,6 +27,11 @@ pub mod gamepad; pub mod keymap; #[cfg(any(target_os = "linux", windows))] pub mod library; +// 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. +#[cfg(any(target_os = "linux", windows))] +pub mod profiles; #[cfg(any(target_os = "linux", windows))] pub mod session; #[cfg(any(target_os = "linux", windows))] diff --git a/crates/pf-client-core/src/profiles.rs b/crates/pf-client-core/src/profiles.rs new file mode 100644 index 00000000..328aa8fc --- /dev/null +++ b/crates/pf-client-core/src/profiles.rs @@ -0,0 +1,507 @@ +//! Client settings profiles — named bundles of setting overrides applied on top of the +//! global [`Settings`] (design/client-settings-profiles.md §4). +//! +//! A profile overrides only the fields the user touched; everything else keeps following the +//! global defaults *live*, so fixing a global once fixes it everywhere. That is why an overlay +//! is sparse `Option`s rather than a snapshot copy, and why `Some(x)` is written on touch and +//! `None` on an explicit "reset to default" — never by diffing against the current global (a +//! `Some` equal to today's global is a legitimate *pin*: the profile keeps `x` when the global +//! later moves). +//! +//! The catalog lives in its own `client-profiles.json` beside the settings file, deliberately +//! NOT inside it: the settings file has five whole-file load-modify-save writers (two shells, +//! the console settings screen, the session's resize callback, Decky) with no merge, so a +//! profile written by one would be dropped by the next. This file is touched only by +//! profile-aware code, and written temp+rename. +//! +//! Which host uses which profile is a field on the host record ([`crate::trust::KnownHost`]), +//! not a map keyed here — see §4.1 of the design for why the catalog owns no host keys. + +use crate::trust::{config_dir, write_atomic, Settings, StatsVerbosity}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// The catalog file's schema version. Bumped only for a breaking shape change — additive +/// fields ride the unknown-key preservation below. +pub const PROFILES_VERSION: u32 = 1; + +/// Every profileable ("tier P") setting, as `Option`: `None` = inherit the global value, +/// live. Tier-H fields (host properties like `clipboard_sync`) and tier-G ones (this +/// device's hardware/endpoints) are deliberately absent — see the design's §3 curation. +/// +/// `extra` preserves keys this build doesn't know (a newer client's tier-P field): the +/// don't-clobber rule that already governs the GTK `ChoiceRow` pickers extends to the whole +/// overlay — opening and saving a profile on an older client must not erase what a newer one +/// stored. +#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct SettingsOverlay { + #[serde(skip_serializing_if = "Option::is_none")] + pub width: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub height: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_hz: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub match_window: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bitrate_kbps: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub render_scale: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub codec: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hdr_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub compositor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub audio_channels: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mic_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub touch_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mouse_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub invert_scroll: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub inhibit_shortcuts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gamepad: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stats_verbosity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fullscreen_on_stream: Option, + /// Overlay keys a newer client wrote and this one doesn't model — carried through a + /// load→save round-trip untouched. + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl SettingsOverlay { + /// The one resolution seam: this overlay on top of `base`. Pure — no store reads, no + /// clock, no environment — so it is fully testable field by field. + pub fn apply(&self, base: &Settings) -> Settings { + let mut s = base.clone(); + if let Some(v) = self.width { + s.width = v; + } + if let Some(v) = self.height { + s.height = v; + } + if let Some(v) = self.refresh_hz { + s.refresh_hz = v; + } + if let Some(v) = self.match_window { + s.match_window = v; + } + if let Some(v) = self.bitrate_kbps { + s.bitrate_kbps = v; + } + if let Some(v) = self.render_scale { + s.render_scale = v; + } + if let Some(v) = &self.codec { + s.codec = v.clone(); + } + if let Some(v) = self.hdr_enabled { + s.hdr_enabled = v; + } + if let Some(v) = &self.compositor { + s.compositor = v.clone(); + } + if let Some(v) = self.audio_channels { + s.audio_channels = v; + } + if let Some(v) = self.mic_enabled { + s.mic_enabled = v; + } + if let Some(v) = &self.touch_mode { + s.touch_mode = v.clone(); + } + if let Some(v) = &self.mouse_mode { + s.mouse_mode = v.clone(); + } + if let Some(v) = self.invert_scroll { + s.invert_scroll = v; + } + if let Some(v) = self.inhibit_shortcuts { + s.inhibit_shortcuts = v; + } + if let Some(v) = &self.gamepad { + s.gamepad = v.clone(); + } + if let Some(v) = self.stats_verbosity { + // Through the setter so the legacy `show_stats` bool stays coherent for + // pre-tier binaries reading the same settings file. + s.set_stats_verbosity(v); + } + if let Some(v) = self.fullscreen_on_stream { + s.fullscreen_on_stream = v; + } + s + } + + /// True when the profile overrides nothing — "inherits everything", the state a freshly + /// created profile starts in. Unknown-key carry-through counts: a profile that only holds + /// a newer client's field is not empty. + pub fn is_empty(&self) -> bool { + *self == SettingsOverlay::default() + } +} + +/// One named bundle of overrides. `id` is stable across renames — bindings, pins and deep +/// links all point at it, never at the name. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StreamProfile { + pub id: String, + /// User-facing and editable; unique case-insensitively (menus are ambiguous otherwise — + /// enforced by the editing UIs via [`ProfilesFile::name_taken`]). + pub name: String, + /// `#RRGGBB` chip color. The UI may ignore it; the schema reserves it (pinned cards use + /// it to tint their subtitle). + #[serde(skip_serializing_if = "Option::is_none")] + pub accent: Option, + #[serde(default)] + pub overrides: SettingsOverlay, + /// Profile keys a newer client wrote — preserved across a load→save round-trip. + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl StreamProfile { + /// A new, empty profile: inherits everything (the right creation default under + /// inherit-by-exception — "Duplicate" covers starting from another profile). + pub fn new(name: impl Into) -> StreamProfile { + StreamProfile { + id: new_profile_id(), + name: name.into(), + accent: None, + overrides: SettingsOverlay::default(), + extra: BTreeMap::new(), + } + } +} + +/// What a `profile=` / `--profile` reference resolved to. Ambiguity is reported rather than +/// guessed: a link or flag naming two profiles must refuse, not pick one (design +/// client-deep-links.md §8). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Resolution { + Found, + NotFound, + /// More than one profile carries this name (case-insensitively). + Ambiguous, +} + +/// The profile catalog — client-wide, not per host: "Work" applied to three hosts is one +/// profile, and the per-host part is only the binding on the host record. +#[derive(Default, Clone, Debug, Serialize, Deserialize)] +pub struct ProfilesFile { + #[serde(default)] + pub version: u32, + #[serde(default)] + pub profiles: Vec, +} + +impl ProfilesFile { + pub fn path() -> anyhow::Result { + Ok(config_dir()?.join("client-profiles.json")) + } + + /// The stored catalog, or an empty one — a missing or unreadable file is "no profiles", + /// never an error: nothing about streaming may hinge on this file existing. + pub fn load() -> ProfilesFile { + Self::path() + .and_then(|p| Ok(std::fs::read_to_string(p)?)) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + + /// Persist temp+rename, so a crash or a full disk mid-write leaves the previous catalog + /// intact instead of a truncated one. + pub fn save(&mut self) -> anyhow::Result<()> { + self.version = PROFILES_VERSION; + let p = Self::path()?; + std::fs::create_dir_all(p.parent().unwrap())?; + write_atomic(&p, &serde_json::to_vec_pretty(self)?)?; + Ok(()) + } + + pub fn find_by_id(&self, id: &str) -> Option<&StreamProfile> { + self.profiles.iter().find(|p| p.id == id) + } + + /// Resolve a reference the way every surface must: exact id first, then a unique + /// case-insensitive name. Ambiguous names resolve to [`Resolution::Ambiguous`], never to + /// the first match. + pub fn resolve(&self, reference: &str) -> (Option<&StreamProfile>, Resolution) { + if let Some(p) = self.find_by_id(reference) { + return (Some(p), Resolution::Found); + } + let mut hits = self + .profiles + .iter() + .filter(|p| p.name.eq_ignore_ascii_case(reference)); + match (hits.next(), hits.next()) { + (Some(p), None) => (Some(p), Resolution::Found), + (Some(_), Some(_)) => (None, Resolution::Ambiguous), + _ => (None, Resolution::NotFound), + } + } + + /// Is this name already used (case-insensitively) by a *different* profile? The + /// create/rename guard — `except` is the profile being renamed, so renaming "Work" to + /// "work" is allowed. + pub fn name_taken(&self, name: &str, except: Option<&str>) -> bool { + self.profiles + .iter() + .any(|p| p.name.eq_ignore_ascii_case(name) && Some(p.id.as_str()) != except) + } +} + +/// 12 lowercase hex chars — the `library::new_id` shape, minted from the OS RNG (no uuid +/// dependency, no collision in any realistic catalog). +pub fn new_profile_id() -> String { + let b: [u8; 6] = rand::random(); + hex_lower(&b) +} + +/// A random UUID-v4 in the canonical 8-4-4-4-12 form — the stable host-record identity +/// (design §4.5). Matches the shape Apple's `StoredHost.id` already has, so a deep link's +/// host-ref grammar is one format on every platform. +pub fn new_record_uuid() -> String { + let mut b: [u8; 16] = rand::random(); + b[6] = (b[6] & 0x0f) | 0x40; // version 4 + b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant + let h = hex_lower(&b); + format!( + "{}-{}-{}-{}-{}", + &h[0..8], + &h[8..12], + &h[12..16], + &h[16..20], + &h[20..32] + ) +} + +fn hex_lower(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The overlay applies field by field: a `Some` wins, a `None` keeps the base's live + /// value — including values that happen to equal the base (an explicit pin). + #[test] + fn overlay_applies_only_what_it_overrides() { + let mut base = Settings::default(); + base.width = 1920; + base.height = 1080; + base.bitrate_kbps = 20000; + base.codec = "hevc".into(); + + let empty = SettingsOverlay::default(); + let out = empty.apply(&base); + assert_eq!((out.width, out.height), (1920, 1080)); + assert_eq!(out.bitrate_kbps, 20000); + assert_eq!(out.codec, "hevc"); + assert!(empty.is_empty()); + + let overlay = SettingsOverlay { + width: Some(3840), + height: Some(2160), + refresh_hz: Some(120), + bitrate_kbps: Some(80000), + render_scale: Some(1.5), + codec: Some("av1".into()), + hdr_enabled: Some(false), + compositor: Some("gamescope".into()), + audio_channels: Some(6), + mic_enabled: Some(true), + touch_mode: Some("pointer".into()), + mouse_mode: Some("desktop".into()), + invert_scroll: Some(true), + inhibit_shortcuts: Some(false), + gamepad: Some("dualsense".into()), + match_window: Some(true), + fullscreen_on_stream: Some(false), + stats_verbosity: Some(StatsVerbosity::Detailed), + ..Default::default() + }; + assert!(!overlay.is_empty()); + let out = overlay.apply(&base); + assert_eq!((out.width, out.height, out.refresh_hz), (3840, 2160, 120)); + assert_eq!(out.bitrate_kbps, 80000); + assert_eq!(out.render_scale, 1.5); + assert_eq!(out.codec, "av1"); + assert!(!out.hdr_enabled); + assert_eq!(out.compositor, "gamescope"); + assert_eq!(out.audio_channels, 6); + assert!(out.mic_enabled); + assert_eq!(out.touch_mode, "pointer"); + assert_eq!(out.mouse_mode, "desktop"); + assert!(out.invert_scroll); + assert!(!out.inhibit_shortcuts); + assert_eq!(out.gamepad, "dualsense"); + assert!(out.match_window); + assert!(!out.fullscreen_on_stream); + assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed); + // The tier goes through the setter, so the legacy bool a pre-tier binary reads + // stays coherent with it. + assert!(out.show_stats); + // Tier-G/H fields are not in the overlay at all — the device's decoder pick, its + // audio endpoints and the per-host clipboard decision survive any profile. + assert_eq!(out.decoder, base.decoder); + assert_eq!(out.speaker_device, base.speaker_device); + + // An overlay that only carries a value equal to the base is still an override: the + // profile pins it, so a later global change doesn't move it. + let pin = SettingsOverlay { + bitrate_kbps: Some(20000), + ..Default::default() + }; + assert!(!pin.is_empty()); + let mut moved = base.clone(); + moved.bitrate_kbps = 50000; + assert_eq!(pin.apply(&moved).bitrate_kbps, 20000); + } + + /// Stats verbosity Off must survive `apply` — it is a legitimate override, and going + /// through `set_stats_verbosity` keeps `show_stats` in sync in that direction too. + #[test] + fn overlay_can_turn_the_stats_overlay_off() { + let mut base = Settings::default(); + base.set_stats_verbosity(StatsVerbosity::Detailed); + let overlay = SettingsOverlay { + stats_verbosity: Some(StatsVerbosity::Off), + ..Default::default() + }; + let out = overlay.apply(&base); + assert_eq!(out.stats_verbosity(), StatsVerbosity::Off); + assert!(!out.show_stats); + } + + /// A catalog round-trips, and values this build can't represent survive it: an unknown + /// codec string (a newer client's option) stays as written, and an unknown overlay KEY + /// is carried through untouched rather than erased — the don't-clobber rule. + #[test] + fn catalog_round_trips_and_preserves_what_it_cannot_represent() { + // `r##` — the accent value below contains a `"#` pair that would close an `r#` literal. + let stored = r##"{ + "version": 1, + "profiles": [ + { + "id": "a1b2c3d4e5f6", + "name": "Game", + "accent": "#ff8800", + "overrides": { + "width": 3840, "height": 2160, "refresh_hz": 120, + "codec": "vvc-from-the-future", + "some_new_axis": {"nested": true}, + "stats_verbosity": "compact" + }, + "future_profile_key": 7 + }, + { "id": "0f0f0f0f0f0f", "name": "Work" } + ] + }"##; + let file: ProfilesFile = serde_json::from_str(stored).unwrap(); + assert_eq!(file.profiles.len(), 2); + let game = file.find_by_id("a1b2c3d4e5f6").unwrap(); + assert_eq!(game.accent.as_deref(), Some("#ff8800")); + assert_eq!(game.overrides.codec.as_deref(), Some("vvc-from-the-future")); + assert_eq!( + game.overrides.stats_verbosity, + Some(StatsVerbosity::Compact) + ); + // A profile with no `overrides` key at all is the empty (inherit-everything) one. + assert!(file + .find_by_id("0f0f0f0f0f0f") + .unwrap() + .overrides + .is_empty()); + + let text = serde_json::to_string(&file).unwrap(); + assert!(text.contains("vvc-from-the-future")); + assert!(text.contains("some_new_axis")); + assert!(text.contains("future_profile_key")); + // Absent overrides serialize away entirely — the file stays readable. + assert!(!text.contains("null")); + let round: ProfilesFile = serde_json::from_str(&text).unwrap(); + let game = round.find_by_id("a1b2c3d4e5f6").unwrap(); + assert_eq!(game.overrides.width, Some(3840)); + assert_eq!(game.overrides.extra.len(), 1); + assert_eq!(game.extra.len(), 1); + + // The unknown value still applies: the session hands the string to the host, which + // is the component that decides what it can encode. + let applied = game.overrides.apply(&Settings::default()); + assert_eq!(applied.codec, "vvc-from-the-future"); + } + + /// Resolution order: id first, then a unique case-insensitive name; two profiles sharing + /// a name resolve to Ambiguous (the caller refuses) rather than to whichever came first. + #[test] + fn resolve_prefers_ids_and_refuses_ambiguity() { + let file = ProfilesFile { + version: 1, + profiles: vec![ + StreamProfile { + id: "111111111111".into(), + name: "Work".into(), + ..StreamProfile::new("") + }, + StreamProfile { + id: "222222222222".into(), + name: "work".into(), + ..StreamProfile::new("") + }, + StreamProfile { + id: "333333333333".into(), + name: "Game".into(), + ..StreamProfile::new("") + }, + ], + }; + assert_eq!(file.resolve("111111111111").1, Resolution::Found); + assert_eq!(file.resolve("Work").1, Resolution::Ambiguous); + assert_eq!(file.resolve("game").1, Resolution::Found); + assert_eq!(file.resolve("GAME").0.unwrap().id, "333333333333"); + assert_eq!(file.resolve("nope").1, Resolution::NotFound); + assert_eq!(file.resolve("").1, Resolution::NotFound); + + // Duplicate-name guard, and the rename-in-place exemption. + assert!(file.name_taken("GAME", None)); + assert!(!file.name_taken("GAME", Some("333333333333"))); + assert!(file.name_taken("GAME", Some("111111111111"))); + assert!(!file.name_taken("Travel", None)); + } + + /// Minted ids have the documented shapes and don't repeat. + #[test] + fn minted_ids_are_well_formed() { + let a = new_profile_id(); + assert_eq!(a.len(), 12); + assert!(a + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())); + assert_ne!(a, new_profile_id()); + + let u = new_record_uuid(); + assert_eq!(u.len(), 36); + let parts: Vec<&str> = u.split('-').collect(); + assert_eq!( + parts.iter().map(|p| p.len()).collect::>(), + vec![8, 4, 4, 4, 12] + ); + assert!(u.chars().all(|c| c == '-' || c.is_ascii_hexdigit())); + assert_eq!(parts[2].as_bytes()[0], b'4'); // version nibble + assert!(matches!(parts[3].as_bytes()[0], b'8' | b'9' | b'a' | b'b')); + assert_ne!(u, new_record_uuid()); + } +} diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index 48d3e684..ee9414dc 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -73,6 +73,11 @@ pub struct SessionParams { /// re-requests a keyframe. Decode itself succeeds in that state, so nothing else /// would recover — without this the stream stays black. pub force_software: Arc, + /// Name of the settings profile these params were resolved with (`None` = the global + /// defaults). Display only — every value it influenced is already baked into the fields + /// above; it rides along so the stats overlay can answer "which profile am I on?" without + /// re-reading any store (design/client-settings-profiles.md §5.2). + pub profile: Option, } /// The session pump's share of the unified stats window (design/stats-unification.md): diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 0476a424..4f2eb434 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -9,11 +9,12 @@ //! shell stays the settings file's only writer (the session only reads). Pre-unification //! shell files (≤ 0.8.4: `show_hud`, `engine`) still load — see the migration test below. +use crate::profiles::{ProfilesFile, Resolution, StreamProfile}; use anyhow::{anyhow, Context, Result}; use punktfunk_core::client::NativeClient; use punktfunk_core::quic::endpoint; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; pub fn config_dir() -> Result { #[cfg(windows)] @@ -89,6 +90,25 @@ fn lock_identity_perms(dir: &std::path::Path, key: &std::path::Path) { let _ = std::fs::set_permissions(key, std::fs::Permissions::from_mode(0o600)); } +/// Write a config file the safe way: a sibling temp file, then a rename over the target. A +/// plain `fs::write` truncates first, so a crash, a full disk or a power cut between truncate +/// and the last byte leaves an empty/half file — and these stores are what a client needs to +/// find its hosts at all. Rename is atomic within a directory on both Unix and Windows +/// (`MoveFileEx` with replace), so a reader ever sees the old file or the new one, never a +/// torn one. Same discipline as the host's `session_settings.rs`. +pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, bytes)?; + match std::fs::rename(&tmp, path) { + Ok(()) => Ok(()), + Err(e) => { + // Don't leave the temp behind to confuse the next writer (or a backup tool). + let _ = std::fs::remove_file(&tmp); + Err(e) + } + } +} + pub fn hex(fp: &[u8; 32]) -> String { fp.iter().map(|b| format!("{b:02x}")).collect() } @@ -130,6 +150,63 @@ pub struct KnownHost { /// also advertise `HOST_CAP_CLIPBOARD` and have its own policy enabled. #[serde(default)] pub clipboard_sync: bool, + /// This host's default settings profile (design/client-settings-profiles.md §4.1) — the + /// one a plain click uses. `None`, or an id whose profile was deleted, means the global + /// defaults, i.e. exactly today's behavior; a dangling binding never errors and never + /// blocks a connect. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_id: Option, + /// Profiles pinned as extra cards for this host (design §5.2a); order = card order. + /// Presentation only — NOT the default (that's `profile_id`) — and duplicates/dangling + /// ids are dropped when the list is resolved against the catalog. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pinned_profiles: Vec, + /// Stable record identity (design §4.5): minted lazily for records that predate it, never + /// changed afterwards, so a deep link or a future cross-reference has something to point + /// at that survives a rename or a new DHCP lease. **No lookup in this crate is keyed by + /// it** — `fp_hex`/`addr:port` stay the lookup keys; this is groundwork. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +impl Default for KnownHost { + /// A blank record with a fresh stable id — the base every construction site builds on + /// (`KnownHost { name, addr, port, ..Default::default() }`), so adding a field here can't + /// silently produce records that lack it. That is not hypothetical: `clipboard_sync` + /// survives today only because [`KnownHosts::upsert`] happens to skip it. + fn default() -> KnownHost { + KnownHost { + name: String::new(), + addr: String::new(), + port: 9777, + fp_hex: String::new(), + paired: false, + last_used: None, + mac: Vec::new(), + clipboard_sync: false, + profile_id: None, + pinned_profiles: Vec::new(), + id: Some(crate::profiles::new_record_uuid()), + } + } +} + +impl KnownHost { + /// This host's pinned profiles that still exist, in card order, without duplicates — what + /// a grid renders. Dangling pins (the profile was deleted) simply disappear, per design + /// §5.2a: a pin is presentation state, never a reason to show an error. + pub fn resolved_pins<'a>(&self, catalog: &'a ProfilesFile) -> Vec<&'a StreamProfile> { + let mut out: Vec<&StreamProfile> = Vec::new(); + for id in &self.pinned_profiles { + if out.iter().any(|p| p.id == *id) { + continue; + } + if let Some(p) = catalog.find_by_id(id) { + out.push(p); + } + } + out + } } #[derive(Default, Serialize, Deserialize)] @@ -142,18 +219,42 @@ impl KnownHosts { Ok(config_dir()?.join("client-known-hosts.json")) } + /// The store, with any pre-[`KnownHost::id`] records given one. The mint is written back + /// best-effort right here rather than "on the next save" so the id a caller sees is the + /// id that is on disk — an identity that changed every load would be worse than none. + /// A read-only config dir just keeps re-minting in memory, which harms nothing: no lookup + /// is keyed by the id yet (design §4.5). pub fn load() -> KnownHosts { - Self::path() + let mut k: KnownHosts = Self::path() .and_then(|p| Ok(std::fs::read_to_string(p)?)) .ok() .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default() + .unwrap_or_default(); + if k.mint_missing_ids() { + let _ = k.save(); + } + k + } + + /// Give every record still missing one a stable id; returns true if anything changed + /// (i.e. whether this needs persisting). Idempotent — a store that has been through it + /// once is left byte-identical. + pub fn mint_missing_ids(&mut self) -> bool { + let mut minted = false; + for h in &mut self.hosts { + if h.id.as_deref().is_none_or(str::is_empty) { + h.id = Some(crate::profiles::new_record_uuid()); + minted = true; + } + } + minted } pub fn save(&self) -> Result<()> { let p = Self::path()?; std::fs::create_dir_all(p.parent().unwrap())?; - std::fs::write(&p, serde_json::to_string_pretty(self)?)?; + // Temp+rename: losing this file to a torn write costs the user every pairing. + write_atomic(&p, serde_json::to_string_pretty(self)?.as_bytes())?; Ok(()) } @@ -189,6 +290,24 @@ impl KnownHosts { if !entry.mac.is_empty() { h.mac = entry.mac; } + // Everything below is state the user set ON this record, which a refresh (a + // reconnect, a re-pair, a rediscovery) never carries and therefore must never + // clear: the per-host clipboard decision — which survives today only because this + // function happens not to mention it — plus the profile binding, its pinned + // cards, and the stable id. Only an upsert that actually carries a value moves + // one of them. + if entry.clipboard_sync { + h.clipboard_sync = true; + } + if entry.profile_id.is_some() { + h.profile_id = entry.profile_id; + } + if !entry.pinned_profiles.is_empty() { + h.pinned_profiles = entry.pinned_profiles; + } + if h.id.as_deref().is_none_or(str::is_empty) { + h.id = entry.id; + } } else { self.hosts.push(entry); } @@ -199,15 +318,17 @@ impl KnownHosts { /// ceremony, delegated approval, headless pairing) ends in. pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: bool) { let mut known = KnownHosts::load(); + // `..Default::default()` deliberately: this builds a record from a trust decision only, + // so every user-set field (clipboard, profile binding, pins) must arrive as "not carried" + // — `upsert` then leaves an existing host's own settings alone. A hand-written literal + // here is how those fields would get silently reset on the next re-pair. known.upsert(KnownHost { name: name.to_string(), addr: addr.to_string(), port, fp_hex: fp_hex.to_string(), paired, - last_used: None, - mac: Vec::new(), - clipboard_sync: false, + ..Default::default() }); let _ = known.save(); } @@ -769,15 +890,82 @@ impl Settings { .unwrap_or_default() } + /// Fire-and-forget by design (a failed settings write must never take a stream down), + /// but temp+rename: this file has five whole-file writers, and a torn one loads as + /// `Default` — i.e. silently resets every setting the user has. pub fn save(&self) { let Ok(p) = Self::path() else { return }; let _ = std::fs::create_dir_all(p.parent().unwrap()); if let Ok(s) = serde_json::to_string_pretty(self) { - let _ = std::fs::write(&p, s); + let _ = write_atomic(&p, s.as_bytes()); } } } +/// The one settings resolver every front-end and the session binary go through +/// (design/client-settings-profiles.md §4.4/§4.6): global defaults, with the profile this +/// connect uses overlaid. +/// +/// ```text +/// effective = overlay(profile).apply(global) +/// profile = one-off override ?? host binding ?? none +/// ``` +/// +/// `one_off` is the "Connect with ▸ X" / `--profile` / `profile=` pick, by id or unique name; +/// `Some("")` forces the global defaults on a bound host. It never rebinds anything — the +/// host's default is changed only by an explicit act in the UI. +/// +/// Nothing here fails: an unknown one-off falls back to the *defaults* (not to the host's +/// binding — a connect that was explicitly asked for "Work" must not silently run "Game"), +/// and a dangling binding resolves as none, exactly today's behavior. The host is looked up +/// by `addr:port`, the same match the per-host clipboard decision has always used — +/// consistency with the shipped precedent beats purity here (§4.6). +pub fn effective_settings( + addr: &str, + port: u16, + one_off: Option<&str>, +) -> (Settings, Option) { + let base = Settings::load(); + let catalog = ProfilesFile::load(); + let bound = KnownHosts::load() + .hosts + .iter() + .find(|h| h.addr == addr && h.port == port) + .and_then(|h| h.profile_id.clone()); + + match resolve_profile(&catalog, bound.as_deref(), one_off) { + Some(p) => (p.overrides.apply(&base), Some(p)), + None => (base, None), + } +} + +/// The profile half of [`effective_settings`], split out so the precedence rules are testable +/// without touching the config directory: one-off pick ?? host binding ?? none. +fn resolve_profile( + catalog: &ProfilesFile, + bound: Option<&str>, + one_off: Option<&str>, +) -> Option { + match one_off { + // `--profile ""` — "Connect with ▸ Default settings" on a bound host. + Some("") => None, + Some(reference) => match catalog.resolve(reference) { + (Some(p), _) => Some(p.clone()), + (_, res) => { + tracing::warn!( + profile = %reference, + ambiguous = res == Resolution::Ambiguous, + "no such settings profile — streaming with the default settings" + ); + None + } + }, + // A binding is an id, never a name: it was written by a picker, and resolving it by + // name would let renaming another profile hijack it. Dangling → the defaults. + None => bound.and_then(|id| catalog.find_by_id(id).cloned()), + } +} + #[cfg(test)] mod tests { use super::*; @@ -882,4 +1070,223 @@ mod tests { assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]); assert!(parse_hex32(&h.fp_hex).is_some()); } + + /// A pre-profiles known-hosts file loads unchanged — no binding, no pins — and its + /// records serialize back without the new keys, so an older client reading the same file + /// sees exactly what it wrote. The id is minted only when `load()` runs (the migration + /// step), not by deserialization. + #[test] + fn known_hosts_migration_is_a_no_op_on_a_pre_profiles_store() { + let old = r#"{"hosts":[{ + "name": "Gaming PC", "addr": "192.168.1.50", "port": 9777, + "fp_hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "paired": true, "clipboard_sync": true + }]}"#; + let mut k: KnownHosts = serde_json::from_str(old).unwrap(); + let h = &k.hosts[0]; + assert_eq!(h.profile_id, None); + assert!(h.pinned_profiles.is_empty()); + assert_eq!(h.id, None); + assert!(h.clipboard_sync); + let text = serde_json::to_string(&k).unwrap(); + assert!(!text.contains("profile_id")); + assert!(!text.contains("pinned_profiles")); + assert!(!text.contains("\"id\"")); + + // Minting is idempotent: the second pass reports nothing to persist and leaves the + // id it handed out alone. + assert!(k.mint_missing_ids()); + let minted = k.hosts[0].id.clone().unwrap(); + assert_eq!(minted.len(), 36); + assert!(!k.mint_missing_ids()); + assert_eq!(k.hosts[0].id.as_deref(), Some(minted.as_str())); + // An empty-string id (a hand-edited store) counts as missing, not as an identity. + k.hosts[0].id = Some(String::new()); + assert!(k.mint_missing_ids()); + assert_ne!(k.hosts[0].id.as_deref(), Some("")); + } + + /// `upsert` refreshes what a reconnect actually knows and preserves what the user set: + /// the profile binding, the pinned cards, the clipboard decision and the stable id all + /// survive a trust-decision upsert that carries none of them (the bug `clipboard_sync` + /// only ever avoided by accident). + #[test] + fn upsert_preserves_user_set_host_state() { + let fp = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let mut k = KnownHosts { + hosts: vec![KnownHost { + name: "Desk".into(), + addr: "192.168.1.50".into(), + port: 9777, + fp_hex: fp.into(), + paired: true, + last_used: Some(1000), + mac: vec!["aa:bb:cc:dd:ee:ff".into()], + clipboard_sync: true, + profile_id: Some("aaaaaaaaaaaa".into()), + pinned_profiles: vec!["bbbbbbbbbbbb".into()], + id: Some("11111111-2222-4333-8444-555555555555".into()), + }], + }; + // What `persist_host` builds: a trust decision, nothing else. + k.upsert(KnownHost { + name: "Desk".into(), + addr: "192.168.1.51".into(), // new lease + port: 9777, + fp_hex: fp.into(), + paired: false, // must not demote + ..Default::default() + }); + let h = &k.hosts[0]; + assert_eq!(k.hosts.len(), 1); + assert_eq!(h.addr, "192.168.1.51"); + assert!(h.paired); + assert_eq!(h.last_used, Some(1000)); + assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]); + assert!(h.clipboard_sync); + assert_eq!(h.profile_id.as_deref(), Some("aaaaaaaaaaaa")); + assert_eq!(h.pinned_profiles, vec!["bbbbbbbbbbbb".to_string()]); + assert_eq!( + h.id.as_deref(), + Some("11111111-2222-4333-8444-555555555555") + ); + + // A carried value does move the binding (that is how the UI rebinds through upsert). + k.upsert(KnownHost { + fp_hex: fp.into(), + profile_id: Some("cccccccccccc".into()), + pinned_profiles: vec!["dddddddddddd".into()], + ..Default::default() + }); + assert_eq!(k.hosts[0].profile_id.as_deref(), Some("cccccccccccc")); + assert_eq!(k.hosts[0].pinned_profiles, vec!["dddddddddddd".to_string()]); + } + + /// Pins render in card order, deduplicated, with deleted profiles simply gone — a pin is + /// presentation state, so a dangling one is never an error surface. + #[test] + fn resolved_pins_drop_duplicates_and_dangling_ids() { + use crate::profiles::{ProfilesFile, StreamProfile}; + let catalog = ProfilesFile { + version: 1, + profiles: vec![ + StreamProfile { + id: "aaaaaaaaaaaa".into(), + name: "Work".into(), + ..StreamProfile::new("") + }, + StreamProfile { + id: "bbbbbbbbbbbb".into(), + name: "Game".into(), + ..StreamProfile::new("") + }, + ], + }; + let h = KnownHost { + pinned_profiles: vec![ + "bbbbbbbbbbbb".into(), + "deleted00000".into(), + "bbbbbbbbbbbb".into(), + "aaaaaaaaaaaa".into(), + ], + ..Default::default() + }; + let names: Vec<&str> = h + .resolved_pins(&catalog) + .iter() + .map(|p| p.name.as_str()) + .collect(); + assert_eq!(names, vec!["Game", "Work"]); + assert!(KnownHost::default().resolved_pins(&catalog).is_empty()); + } + + /// The connect-time precedence: a one-off pick beats the host's binding, `""` forces the + /// defaults, a dangling binding resolves as none, and a one-off that can't be honored + /// falls back to the DEFAULTS rather than to the host's own profile — "connect with Work" + /// must never quietly run "Game". + #[test] + fn profile_resolution_precedence() { + use crate::profiles::{ProfilesFile, StreamProfile}; + let catalog = ProfilesFile { + version: 1, + profiles: vec![ + StreamProfile { + id: "aaaaaaaaaaaa".into(), + name: "Game".into(), + ..StreamProfile::new("") + }, + StreamProfile { + id: "bbbbbbbbbbbb".into(), + name: "Work".into(), + ..StreamProfile::new("") + }, + StreamProfile { + id: "cccccccccccc".into(), + name: "work".into(), + ..StreamProfile::new("") + }, + ], + }; + let name_of = |p: Option| p.map(|p| p.name); + + // No binding, no pick: today's behavior. + assert_eq!(resolve_profile(&catalog, None, None), None); + // The binding drives a plain connect… + assert_eq!( + name_of(resolve_profile(&catalog, Some("aaaaaaaaaaaa"), None)), + Some("Game".into()) + ); + // …a one-off overrides it, by id or by unique name… + assert_eq!( + name_of(resolve_profile( + &catalog, + Some("aaaaaaaaaaaa"), + Some("bbbbbbbbbbbb") + )), + Some("Work".into()) + ); + assert_eq!( + name_of(resolve_profile(&catalog, None, Some("GAME"))), + Some("Game".into()) + ); + // …and `""` forces the defaults on a bound host. + assert_eq!( + resolve_profile(&catalog, Some("aaaaaaaaaaaa"), Some("")), + None + ); + // A deleted binding is not an error, it is "no profile". + assert_eq!(resolve_profile(&catalog, Some("deleted00000"), None), None); + // Unknown and ambiguous one-offs fall back to the defaults, NOT to the binding. + assert_eq!( + resolve_profile(&catalog, Some("aaaaaaaaaaaa"), Some("nope")), + None + ); + assert_eq!( + resolve_profile(&catalog, Some("aaaaaaaaaaaa"), Some("work")), + None + ); + // A binding resolves by id only — a profile NAMED like the bound id doesn't hijack it. + assert_eq!(resolve_profile(&catalog, Some("Game"), None), None); + } + + /// The atomic write replaces the target in one step and leaves no temp behind — the + /// discipline all three client stores now share. + #[test] + fn write_atomic_replaces_and_cleans_up() { + let dir = std::env::temp_dir().join(format!( + "pf-client-core-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("store.json"); + write_atomic(&p, b"{\"a\":1}").unwrap(); + assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":1}"); + write_atomic(&p, b"{\"a\":2}").unwrap(); + assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":2}"); + assert!(!p.with_extension("json.tmp").exists()); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index 35d28295..a71b7e2a 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -185,6 +185,9 @@ struct StreamState { canceled: bool, ready_announced: bool, mode_line: String, + /// The settings profile this session resolved with, for the stats overlay's first line + /// ("which profile am I on?"). `None` = the global defaults, and nothing is shown. + profile: Option, /// Live host↔client clock offset handle (None until Connected): loaded per present so /// mid-stream re-syncs keep the end-to-end number honest after an NTP step / drift. clock_offset: Option>, @@ -260,6 +263,7 @@ impl StreamState { force_software: Arc, wake: sdl3::event::EventSender, ) -> StreamState { + let profile = params.profile.clone(); let handle = session::start(params); let (wake_tx, wake_rx) = async_channel::bounded(2); let pump_rx = handle.frames.clone(); @@ -284,6 +288,7 @@ impl StreamState { canceled: false, ready_announced: false, mode_line: String::new(), + profile, clock_offset: None, hdr: false, win_e2e_us: Vec::with_capacity(256), @@ -1031,6 +1036,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result &st.presented, st.hdr, presenter.hdr_active(), + st.profile.as_deref(), ); if stats_verbosity != StatsVerbosity::Off { // The stdout line is the machine interface (shell status card, @@ -1042,6 +1048,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result &st.presented, st.hdr, presenter.hdr_active(), + st.profile.as_deref(), ); println!("stats: {}", full.replace('\n', " | ")); } @@ -1770,6 +1777,7 @@ fn bump_stats_tier( &st.presented, st.hdr, presenter.hdr_active(), + st.profile.as_deref(), ), None => String::new(), }; @@ -1867,6 +1875,10 @@ const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift /// The HDR tag is honest about the display path: `HDR` only when the swapchain actually /// runs HDR10 (`hdr_display`); a PQ stream tone-mapped onto an SDR surface (no HDR10 /// format offered, HDR off in the compositor) shows `HDR→SDR` instead. +/// +/// `profile` (the session's settings profile, `None` for the global defaults) closes the +/// first line at every tier — the cheapest possible answer to "which profile am I on?" +/// (design/client-settings-profiles.md §5.2). fn stats_text( verbosity: StatsVerbosity, mode_line: &str, @@ -1874,7 +1886,9 @@ fn stats_text( p: &PresentedWindow, hdr_stream: bool, hdr_display: bool, + profile: Option<&str>, ) -> String { + let profile_tag = profile.map(|n| format!(" · {n}")).unwrap_or_default(); match verbosity { StatsVerbosity::Off => return String::new(), StatsVerbosity::Compact => { @@ -1888,6 +1902,7 @@ fn stats_text( if s.lost > 0 { text.push_str(&format!(" · lost {}", s.lost)); } + text.push_str(&profile_tag); return text; } StatsVerbosity::Normal | StatsVerbosity::Detailed => {} @@ -1908,6 +1923,7 @@ fn stats_text( } else { format!("{mode_line} · {:.0} fps · {:.1} Mb/s", s.fps, s.mbps) }; + text.push_str(&profile_tag); text.push_str(&format!( "\ne2e {:.1}/{:.1} ms (p50/p95)", p.e2e_p50_ms, p.e2e_p95_ms @@ -2194,7 +2210,7 @@ mod tests { #[test] fn stats_text_tiers() { let (s, p) = sample(); - let text = |v| stats_text(v, "1920×1080@120", &s, &p, true, false); + let text = |v| stats_text(v, "1920×1080@120", &s, &p, true, false, None); assert_eq!(text(StatsVerbosity::Off), ""); @@ -2227,11 +2243,57 @@ mod tests { s.lost = 0; let p = PresentedWindow::default(); assert_eq!( - stats_text(StatsVerbosity::Compact, "m", &s, &p, false, false), + stats_text(StatsVerbosity::Compact, "m", &s, &p, false, false, None), "120 fps · 24 Mb/s" ); } + /// The session's settings profile closes the FIRST line at every tier — one line in + /// Compact, the mode line in Normal/Detailed — and nothing renders without one. + #[test] + fn stats_text_names_the_active_profile() { + let (s, p) = sample(); + assert_eq!( + stats_text( + StatsVerbosity::Compact, + "m", + &s, + &p, + false, + false, + Some("Game") + ), + "120 fps · 6.4 ms · 24 Mb/s · lost 3 · Game" + ); + let normal = stats_text( + StatsVerbosity::Normal, + "1920×1080@120", + &s, + &p, + false, + false, + Some("Work"), + ); + assert_eq!( + normal.lines().next().unwrap(), + "1920×1080@120 · 120 fps · 24.3 Mb/s · Work" + ); + let detailed = stats_text( + StatsVerbosity::Detailed, + "1920×1080@120", + &s, + &p, + true, + true, + Some("Work"), + ); + assert!(detailed.lines().next().unwrap().ends_with("· HDR · Work")); + // No profile → the line is exactly what it always was. + assert!( + !stats_text(StatsVerbosity::Normal, "m", &s, &p, false, false, None).contains(" · ") + ); + } + #[test] fn finger_maps_across_a_perfectly_filled_surface() { // Video exactly fills the window (no letterbox): normalized finger → content