feat(client): settings profiles — the override catalog, the one resolver, and the session flag

A client setting is global today: pick 4K@120 HDR and the retro box gets it too.
The one escape hatch (per-host `clipboard_sync`) proves the shape works but covers a
single field. This is P0 of design/client-settings-profiles.md — the headless core the
shells, Apple, Android and the deep-link grammar all build against. No UI yet.

A profile is a NAMED BUNDLE OF OVERRIDES, not a snapshot: `SettingsOverlay` is sparse
`Option`s, so a field the user never touched keeps following the global value live, and
fixing a global once fixes it everywhere. `Some(x)` equal to today's global is still
meaningful — it pins x against a later global change — which is why the UI will write
`Some` on touch and `None` on an explicit reset, never by diffing.

The catalog is its own `client-profiles.json`, deliberately not part of the settings
file: that file has five whole-file load-modify-save writers with no merge, so a profile
written by one would be dropped by the next. Which host uses which profile is a field on
the host record instead of a map keyed by host — that is what dissolves the client's
long-standing "what identifies a host record" question for this feature.

Resolution has exactly one implementation, `trust::effective_settings()`:

    effective = overlay(profile).apply(global)
    profile   = one-off pick ?? host binding ?? none

Both session construction sites go through it (`main.rs` AND `console.rs` — touching one
and not the other is a Windows-only build break), so the console, and therefore Decky,
honor host bindings with no work of their own. Nothing here can fail a connect: a
deleted binding resolves as "no profile", 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 stream "Game". `--profile <id|name>` is the one-off door for the shells' coming
"Connect with ▸" and for `punktfunk://…&profile=`; `--profile ""` forces the defaults on
a bound host. The active profile's name closes the stats overlay's first line, so
"which profile am I on?" is answerable without leaving the stream.

Also here, because this effort touches every one of these anyway:

- `KnownHost` gains `profile_id`, `pinned_profiles` and a lazily minted stable `id`. The
  id is groundwork (design §4.5) — no lookup is re-keyed, `fp_hex`/`addr:port` stay.
- `upsert` now preserves the state the USER set on a record — the profile binding, its
  pins, the clipboard decision, the id — against refreshes that carry none of it.
  `clipboard_sync` survived that only because the function happened not to mention it.
- `KnownHost::default()` exists so construction sites read `..Default::default()`; five
  hand-written literals were exactly how a new field gets silently dropped on re-pair.
- All three client stores now write temp+rename. A torn settings file loads as `Default`
  — i.e. silently resets every setting the user has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-28 20:22:33 +02:00
co-authored by Claude Opus 5
parent 33a31427ae
commit a2be3f4de9
12 changed files with 1043 additions and 34 deletions
Generated
+1
View File
@@ -2863,6 +2863,7 @@ dependencies = [
"pipewire", "pipewire",
"punktfunk-core", "punktfunk-core",
"pyrowave-sys", "pyrowave-sys",
"rand 0.9.4",
"rustls", "rustls",
"sdl3", "sdl3",
"serde", "serde",
+1 -5
View File
@@ -336,11 +336,7 @@ pub fn headless_add_host(target: &str) -> glib::ExitCode {
name, name,
addr: addr.clone(), addr: addr.clone(),
port, port,
fp_hex: String::new(), ..Default::default()
paired: false,
last_used: None,
mac: Vec::new(),
clipboard_sync: false,
}); });
} }
match known.save() { match known.save() {
+9 -8
View File
@@ -201,11 +201,16 @@ pub fn run(target: Option<&str>) -> u8 {
tracing::info!(%addr, %title, request_access, tracing::info!(%addr, %title, request_access,
launch = launch.as_deref().unwrap_or("desktop"), launch = launch.as_deref().unwrap_or("desktop"),
"launching from the console"); "launching from the console");
// Settings re-load per launch: the console's own settings screen // Settings re-resolve per launch: the console's own settings screen may
// may have changed them since the last stream. // have changed the defaults since the last stream, and the host may carry
let settings = trust::Settings::load(); // 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( let mut params = session_params(
&settings, &settings,
profile.map(|p| p.name),
addr.clone(), addr.clone(),
port, port,
pin, pin,
@@ -434,11 +439,7 @@ impl ServiceState {
name: if name.is_empty() { addr.clone() } else { name }, name: if name.is_empty() { addr.clone() } else { name },
addr, addr,
port, port,
fp_hex: String::new(), ..Default::default()
paired: false,
last_used: None,
mac: Vec::new(),
clipboard_sync: false,
}); });
} }
if let Err(e) = known.save() { if let Err(e) = known.save() {
+30 -4
View File
@@ -124,6 +124,15 @@ mod session_main {
} }
} }
/// `--profile <id|name>` — 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<String> {
arg_flag("--profile").then(|| arg_value("--profile").unwrap_or_default())
}
/// The connect budget: 15 s normally; `--connect-timeout SECS` overrides — the /// 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 /// shell's request-access flow passes ~185 s because the host PARKS the connection
/// until the operator clicks Approve. /// 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 /// and every `--browse` launch. Explicit settings, `0` fields resolved to the
/// window's display (the GTK client reads the monitor under its window — same /// window's display (the GTK client reads the monitor under its window — same
/// contract). /// 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)] #[allow(clippy::too_many_arguments)]
pub(crate) fn session_params( pub(crate) fn session_params(
settings: &trust::Settings, settings: &trust::Settings,
profile: Option<String>,
addr: String, addr: String,
port: u16, port: u16,
pin: [u8; 32], pin: [u8; 32],
@@ -246,6 +262,7 @@ mod session_main {
identity, identity,
connect_timeout: connect_timeout(), connect_timeout: connect_timeout(),
force_software, force_software,
profile,
} }
} }
@@ -431,14 +448,16 @@ mod session_main {
} }
let Some(target) = arg_value("--connect") else { let Some(target) = arg_value("--connect") else {
eprintln!( 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 --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\
\x20 punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]\n\ \x20 punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]\n\
\n\ \n\
Streams from a paired punktfunk host in a Vulkan window. --browse opens the\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\ 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\ 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\ enrol with --pair (no display needed), in the console, or from the desktop\n\
client." client."
); );
@@ -453,7 +472,13 @@ mod session_main {
return EXIT_CONNECT_FAILED; 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 <id|name>` 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 // 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 // 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| { pf_presenter::run_session(opts, move |gamepad, native, force_software, vulkan| {
session_params( session_params(
&settings, &settings,
profile.map(|p| p.name),
addr, addr,
port, port,
pin, pin,
+2 -5
View File
@@ -272,9 +272,8 @@ fn connect_spawn(
port: target.port, port: target.port,
fp_hex: fp_hex.clone(), fp_hex: fp_hex.clone(),
paired: persist_paired, paired: persist_paired,
last_used: None,
mac: target.mac.clone(), mac: target.mac.clone(),
clipboard_sync: false, ..Default::default()
}); });
let _ = k.save(); let _ = k.save();
} }
@@ -481,10 +480,8 @@ fn wake_and_connect(
addr: target.addr.clone(), addr: target.addr.clone(),
port: target.port, port: target.port,
fp_hex: fp, fp_hex: fp,
paired: false,
last_used: None,
mac: target.mac.clone(), mac: target.mac.clone(),
clipboard_sync: false, ..Default::default()
}); });
let _ = k.save(); let _ = k.save();
} }
+1 -2
View File
@@ -57,9 +57,8 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
port: target3.port, port: target3.port,
fp_hex: trust::hex(&fp), fp_hex: trust::hex(&fp),
paired: true, paired: true,
last_used: None,
mac: target3.mac.clone(), mac: target3.mac.clone(),
clipboard_sync: false, ..Default::default()
}); });
let _ = k.save(); let _ = k.save();
connect(&ctx3, &target3, Some(fp), &ss, &st); connect(&ctx3, &target3, Some(fp), &ss, &st);
+3
View File
@@ -41,6 +41,9 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
anyhow = "1" anyhow = "1"
tracing = "0.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 # Gamepads: capture + feedback (full DualSense fidelity — touchpad/motion/triggers/LEDs
# need the hidapi driver). Linux links the system SDL3; Windows builds it from source # need the hidapi driver). Linux links the system SDL3; Windows builds it from source
+5
View File
@@ -27,6 +27,11 @@ pub mod gamepad;
pub mod keymap; pub mod keymap;
#[cfg(any(target_os = "linux", windows))] #[cfg(any(target_os = "linux", windows))]
pub mod library; 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))] #[cfg(any(target_os = "linux", windows))]
pub mod session; pub mod session;
#[cfg(any(target_os = "linux", windows))] #[cfg(any(target_os = "linux", windows))]
+507
View File
@@ -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<T>`: `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<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_hz: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub match_window: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bitrate_kbps: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub render_scale: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub codec: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hdr_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compositor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio_channels: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mic_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub touch_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mouse_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub invert_scroll: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inhibit_shortcuts: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gamepad: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stats_verbosity: Option<StatsVerbosity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fullscreen_on_stream: Option<bool>,
/// 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<String, serde_json::Value>,
}
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<String>,
#[serde(default)]
pub overrides: SettingsOverlay,
/// Profile keys a newer client wrote — preserved across a load→save round-trip.
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
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<String>) -> 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<StreamProfile>,
}
impl ProfilesFile {
pub fn path() -> anyhow::Result<PathBuf> {
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<_>>(),
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());
}
}
+5
View File
@@ -73,6 +73,11 @@ pub struct SessionParams {
/// re-requests a keyframe. Decode itself succeeds in that state, so nothing else /// re-requests a keyframe. Decode itself succeeds in that state, so nothing else
/// would recover — without this the stream stays black. /// would recover — without this the stream stays black.
pub force_software: Arc<AtomicBool>, pub force_software: Arc<AtomicBool>,
/// 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<String>,
} }
/// The session pump's share of the unified stats window (design/stats-unification.md): /// The session pump's share of the unified stats window (design/stats-unification.md):
+415 -8
View File
@@ -9,11 +9,12 @@
//! shell stays the settings file's only writer (the session only reads). Pre-unification //! 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. //! 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 anyhow::{anyhow, Context, Result};
use punktfunk_core::client::NativeClient; use punktfunk_core::client::NativeClient;
use punktfunk_core::quic::endpoint; use punktfunk_core::quic::endpoint;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::{Path, PathBuf};
pub fn config_dir() -> Result<PathBuf> { pub fn config_dir() -> Result<PathBuf> {
#[cfg(windows)] #[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)); 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 { pub fn hex(fp: &[u8; 32]) -> String {
fp.iter().map(|b| format!("{b:02x}")).collect() 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. /// also advertise `HOST_CAP_CLIPBOARD` and have its own policy enabled.
#[serde(default)] #[serde(default)]
pub clipboard_sync: bool, 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<String>,
/// 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<String>,
/// 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<String>,
}
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)] #[derive(Default, Serialize, Deserialize)]
@@ -142,18 +219,42 @@ impl KnownHosts {
Ok(config_dir()?.join("client-known-hosts.json")) 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 { pub fn load() -> KnownHosts {
Self::path() let mut k: KnownHosts = Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?)) .and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok() .ok()
.and_then(|s| serde_json::from_str(&s).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<()> { pub fn save(&self) -> Result<()> {
let p = Self::path()?; let p = Self::path()?;
std::fs::create_dir_all(p.parent().unwrap())?; 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(()) Ok(())
} }
@@ -189,6 +290,24 @@ impl KnownHosts {
if !entry.mac.is_empty() { if !entry.mac.is_empty() {
h.mac = entry.mac; 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 { } else {
self.hosts.push(entry); self.hosts.push(entry);
} }
@@ -199,15 +318,17 @@ impl KnownHosts {
/// ceremony, delegated approval, headless pairing) ends in. /// ceremony, delegated approval, headless pairing) ends in.
pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: bool) { pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: bool) {
let mut known = KnownHosts::load(); 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 { known.upsert(KnownHost {
name: name.to_string(), name: name.to_string(),
addr: addr.to_string(), addr: addr.to_string(),
port, port,
fp_hex: fp_hex.to_string(), fp_hex: fp_hex.to_string(),
paired, paired,
last_used: None, ..Default::default()
mac: Vec::new(),
clipboard_sync: false,
}); });
let _ = known.save(); let _ = known.save();
} }
@@ -769,15 +890,82 @@ impl Settings {
.unwrap_or_default() .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) { pub fn save(&self) {
let Ok(p) = Self::path() else { return }; let Ok(p) = Self::path() else { return };
let _ = std::fs::create_dir_all(p.parent().unwrap()); let _ = std::fs::create_dir_all(p.parent().unwrap());
if let Ok(s) = serde_json::to_string_pretty(self) { 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<StreamProfile>) {
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<StreamProfile> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -882,4 +1070,223 @@ mod tests {
assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]); assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]);
assert!(parse_hex32(&h.fp_hex).is_some()); 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<StreamProfile>| 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);
}
} }
+64 -2
View File
@@ -185,6 +185,9 @@ struct StreamState {
canceled: bool, canceled: bool,
ready_announced: bool, ready_announced: bool,
mode_line: String, 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<String>,
/// Live host↔client clock offset handle (None until Connected): loaded per present so /// 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. /// mid-stream re-syncs keep the end-to-end number honest after an NTP step / drift.
clock_offset: Option<Arc<std::sync::atomic::AtomicI64>>, clock_offset: Option<Arc<std::sync::atomic::AtomicI64>>,
@@ -260,6 +263,7 @@ impl StreamState {
force_software: Arc<AtomicBool>, force_software: Arc<AtomicBool>,
wake: sdl3::event::EventSender, wake: sdl3::event::EventSender,
) -> StreamState { ) -> StreamState {
let profile = params.profile.clone();
let handle = session::start(params); let handle = session::start(params);
let (wake_tx, wake_rx) = async_channel::bounded(2); let (wake_tx, wake_rx) = async_channel::bounded(2);
let pump_rx = handle.frames.clone(); let pump_rx = handle.frames.clone();
@@ -284,6 +288,7 @@ impl StreamState {
canceled: false, canceled: false,
ready_announced: false, ready_announced: false,
mode_line: String::new(), mode_line: String::new(),
profile,
clock_offset: None, clock_offset: None,
hdr: false, hdr: false,
win_e2e_us: Vec::with_capacity(256), win_e2e_us: Vec::with_capacity(256),
@@ -1031,6 +1036,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
&st.presented, &st.presented,
st.hdr, st.hdr,
presenter.hdr_active(), presenter.hdr_active(),
st.profile.as_deref(),
); );
if stats_verbosity != StatsVerbosity::Off { if stats_verbosity != StatsVerbosity::Off {
// The stdout line is the machine interface (shell status card, // The stdout line is the machine interface (shell status card,
@@ -1042,6 +1048,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
&st.presented, &st.presented,
st.hdr, st.hdr,
presenter.hdr_active(), presenter.hdr_active(),
st.profile.as_deref(),
); );
println!("stats: {}", full.replace('\n', " | ")); println!("stats: {}", full.replace('\n', " | "));
} }
@@ -1770,6 +1777,7 @@ fn bump_stats_tier(
&st.presented, &st.presented,
st.hdr, st.hdr,
presenter.hdr_active(), presenter.hdr_active(),
st.profile.as_deref(),
), ),
None => String::new(), 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 /// 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 /// 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. /// 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( fn stats_text(
verbosity: StatsVerbosity, verbosity: StatsVerbosity,
mode_line: &str, mode_line: &str,
@@ -1874,7 +1886,9 @@ fn stats_text(
p: &PresentedWindow, p: &PresentedWindow,
hdr_stream: bool, hdr_stream: bool,
hdr_display: bool, hdr_display: bool,
profile: Option<&str>,
) -> String { ) -> String {
let profile_tag = profile.map(|n| format!(" · {n}")).unwrap_or_default();
match verbosity { match verbosity {
StatsVerbosity::Off => return String::new(), StatsVerbosity::Off => return String::new(),
StatsVerbosity::Compact => { StatsVerbosity::Compact => {
@@ -1888,6 +1902,7 @@ fn stats_text(
if s.lost > 0 { if s.lost > 0 {
text.push_str(&format!(" · lost {}", s.lost)); text.push_str(&format!(" · lost {}", s.lost));
} }
text.push_str(&profile_tag);
return text; return text;
} }
StatsVerbosity::Normal | StatsVerbosity::Detailed => {} StatsVerbosity::Normal | StatsVerbosity::Detailed => {}
@@ -1908,6 +1923,7 @@ fn stats_text(
} else { } else {
format!("{mode_line} · {:.0} fps · {:.1} Mb/s", s.fps, s.mbps) format!("{mode_line} · {:.0} fps · {:.1} Mb/s", s.fps, s.mbps)
}; };
text.push_str(&profile_tag);
text.push_str(&format!( text.push_str(&format!(
"\ne2e {:.1}/{:.1} ms (p50/p95)", "\ne2e {:.1}/{:.1} ms (p50/p95)",
p.e2e_p50_ms, p.e2e_p95_ms p.e2e_p50_ms, p.e2e_p95_ms
@@ -2194,7 +2210,7 @@ mod tests {
#[test] #[test]
fn stats_text_tiers() { fn stats_text_tiers() {
let (s, p) = sample(); 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), ""); assert_eq!(text(StatsVerbosity::Off), "");
@@ -2227,11 +2243,57 @@ mod tests {
s.lost = 0; s.lost = 0;
let p = PresentedWindow::default(); let p = PresentedWindow::default();
assert_eq!( 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" "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] #[test]
fn finger_maps_across_a_perfectly_filled_surface() { fn finger_maps_across_a_perfectly_filled_surface() {
// Video exactly fills the window (no letterbox): normalized finger → content // Video exactly fills the window (no letterbox): normalized finger → content