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:
@@ -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
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<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):
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
#[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<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)]
|
||||
@@ -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<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)]
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<Arc<std::sync::atomic::AtomicI64>>,
|
||||
@@ -260,6 +263,7 @@ impl StreamState {
|
||||
force_software: Arc<AtomicBool>,
|
||||
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<Option<Outcome>
|
||||
&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<Option<Outcome>
|
||||
&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
|
||||
|
||||
Reference in New Issue
Block a user