WP1+WP2 of design/desktop-presentation-rebuild.md. The shared Linux/Windows session client presented arrival-paced with no pacing layer at all: two depth-2 newest-wins hops into a drain-to-newest and an immediate present. That IS the lowest-latency intent, but it was unnamed, unselectable, and had no alternative — and on a surface without MAILBOX (AMD's Windows driver offers none, and any compositor holding images does the same) the swapchain's own FIFO becomes a standing queue worth a measured 11-13 ms at 60 Hz. WP1 — the settings cluster, under the keys the Apple client already writes into the shared profile catalog (present_priority / smooth_buffer / vsync / allow_vrr): mismatched names would ride SettingsOverlay::extra, carried but never applied. PresentPriority::resolve mirrors the Android reference exactly (anything but an explicit "smooth" is latency; a buffer outside 1..=3 becomes 2), so a profile authored on any client means the same thing on all of them. Only the first two are consumed here; vsync/allow_vrr land in WP3. WP2 — the engine (present_pace.rs, pure state + arithmetic, 6 tests): - FrameStore: newest-wins slot, or the smoothing FIFO with preroll-to-capacity, drop-oldest overflow, and an underflow that re-arms the preroll (repeat by omission) — the Apple/Android semantics, with qDrop/qDry counters. - LatchClock: the panel grid learned from VK_KHR_present_wait glass stamps, min positive spacing capped by the mode refresh (measured, never queried — VRR and Android's per-uid refresh lie both punish trusting a reported rate). It now also publishes the host-facing LatchGrid, so the phase-lock report and the local scheduler cannot disagree about the grid. - PresentGate: one undisplayed present in flight on FIFO surfaces, with the 100 ms stale force-open. This is the standing-queue killer, and it is inert on MAILBOX/IMMEDIATE and without present timing — where behaviour stays byte-for-byte the shipped arrival pacing. Wiring: glass samples drain every pass (a 1 Hz batch would starve clock and gate) and the waiter pushes an SDL wake, so a gate reopen never waits out the event timeout; smoothness serves one frame per latch slot and tightens the loop's wait to that deadline; the adaptive slot margin starts at 0 and widens +500 us per missed window toward 2.5 ms (a fixed lead was measured to be pure display tax). PUNKTFUNK_PRESENTER=arrival disables the whole engine for field A/B without a rebuild. PyroWave collapses smoothness to latency for the stream: its plane-ring retirement accounting assumes the depth-2 newest-wins hand-off, and all-intra frames make buffering moot anyway. Gates (punktfunk-rust-ci, linux/amd64, sources touched first so a warm target cannot print a vacuous Finished): clippy -D warnings across pf-client-core, pf-presenter and punktfunk-client-session; 80 + 32 tests pass; rustfmt clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
867 lines
35 KiB
Rust
867 lines
35 KiB
Rust
//! 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 enable_444: 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 echo_cancel: 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 gamepad_forwarding: Option<bool>,
|
|
#[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>,
|
|
/// The presentation cluster — the keys the Apple client already writes into this
|
|
/// same catalog shape (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`;
|
|
/// Android carries the first two). First-class here so a profile authored on any
|
|
/// client applies on all of them instead of riding `extra` unapplied.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub present_priority: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub smooth_buffer: Option<u8>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub vsync: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub allow_vrr: 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.enable_444 {
|
|
s.enable_444 = 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.echo_cancel {
|
|
s.echo_cancel = 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.gamepad_forwarding {
|
|
s.gamepad_forwarding = v;
|
|
}
|
|
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;
|
|
}
|
|
if let Some(v) = &self.present_priority {
|
|
s.present_priority = v.clone();
|
|
}
|
|
if let Some(v) = self.smooth_buffer {
|
|
s.smooth_buffer = v;
|
|
}
|
|
if let Some(v) = self.vsync {
|
|
s.vsync = v;
|
|
}
|
|
if let Some(v) = self.allow_vrr {
|
|
s.allow_vrr = v;
|
|
}
|
|
s
|
|
}
|
|
|
|
/// Record, as overrides, every tier-P field that differs between two settings snapshots.
|
|
///
|
|
/// This is for front-ends that commit PER CONTROL rather than per dialog (the WinUI shell
|
|
/// writes on every change; the GTK one writes once on close). They can't hand over a list
|
|
/// of touched fields, so they hand over "the effective settings before this control fired"
|
|
/// and "after": the only field that can differ is the one the user just touched.
|
|
///
|
|
/// That is not the diff-on-save this design rejects. The comparison is against the
|
|
/// EFFECTIVE settings — what the control was showing — not against the globals, so setting
|
|
/// a value back to what the global happens to be still records an override, which is the
|
|
/// pin the design asks for. It only ever adds overrides; removing one is an explicit
|
|
/// reset, which is a different operation.
|
|
pub fn absorb(&mut self, before: &Settings, after: &Settings) {
|
|
if after.width != before.width {
|
|
self.width = Some(after.width);
|
|
}
|
|
if after.height != before.height {
|
|
self.height = Some(after.height);
|
|
}
|
|
if after.refresh_hz != before.refresh_hz {
|
|
self.refresh_hz = Some(after.refresh_hz);
|
|
}
|
|
if after.match_window != before.match_window {
|
|
self.match_window = Some(after.match_window);
|
|
}
|
|
if after.bitrate_kbps != before.bitrate_kbps {
|
|
self.bitrate_kbps = Some(after.bitrate_kbps);
|
|
}
|
|
if after.render_scale != before.render_scale {
|
|
self.render_scale = Some(after.render_scale);
|
|
}
|
|
if after.codec != before.codec {
|
|
self.codec = Some(after.codec.clone());
|
|
}
|
|
if after.hdr_enabled != before.hdr_enabled {
|
|
self.hdr_enabled = Some(after.hdr_enabled);
|
|
}
|
|
if after.enable_444 != before.enable_444 {
|
|
self.enable_444 = Some(after.enable_444);
|
|
}
|
|
if after.compositor != before.compositor {
|
|
self.compositor = Some(after.compositor.clone());
|
|
}
|
|
if after.audio_channels != before.audio_channels {
|
|
self.audio_channels = Some(after.audio_channels);
|
|
}
|
|
if after.mic_enabled != before.mic_enabled {
|
|
self.mic_enabled = Some(after.mic_enabled);
|
|
}
|
|
if after.echo_cancel != before.echo_cancel {
|
|
self.echo_cancel = Some(after.echo_cancel);
|
|
}
|
|
if after.touch_mode != before.touch_mode {
|
|
self.touch_mode = Some(after.touch_mode.clone());
|
|
}
|
|
if after.mouse_mode != before.mouse_mode {
|
|
self.mouse_mode = Some(after.mouse_mode.clone());
|
|
}
|
|
if after.invert_scroll != before.invert_scroll {
|
|
self.invert_scroll = Some(after.invert_scroll);
|
|
}
|
|
if after.inhibit_shortcuts != before.inhibit_shortcuts {
|
|
self.inhibit_shortcuts = Some(after.inhibit_shortcuts);
|
|
}
|
|
if after.gamepad != before.gamepad {
|
|
self.gamepad = Some(after.gamepad.clone());
|
|
}
|
|
if after.gamepad_forwarding != before.gamepad_forwarding {
|
|
self.gamepad_forwarding = Some(after.gamepad_forwarding);
|
|
}
|
|
if after.stats_verbosity() != before.stats_verbosity() {
|
|
self.stats_verbosity = Some(after.stats_verbosity());
|
|
}
|
|
if after.fullscreen_on_stream != before.fullscreen_on_stream {
|
|
self.fullscreen_on_stream = Some(after.fullscreen_on_stream);
|
|
}
|
|
if after.present_priority != before.present_priority {
|
|
self.present_priority = Some(after.present_priority.clone());
|
|
}
|
|
if after.smooth_buffer != before.smooth_buffer {
|
|
self.smooth_buffer = Some(after.smooth_buffer);
|
|
}
|
|
if after.vsync != before.vsync {
|
|
self.vsync = Some(after.vsync);
|
|
}
|
|
if after.allow_vrr != before.allow_vrr {
|
|
self.allow_vrr = Some(after.allow_vrr);
|
|
}
|
|
}
|
|
|
|
/// Drop one override by its overlay field name, putting the row back to inheriting. The
|
|
/// names are the serialised ones, so a UI can carry them as plain strings; `resolution`
|
|
/// is the one alias, covering the width/height/match-window tri-state a single control
|
|
/// drives on every client. Returns false for a name this build doesn't know.
|
|
pub fn clear(&mut self, field: &str) -> bool {
|
|
match field {
|
|
"resolution" => {
|
|
self.width = None;
|
|
self.height = None;
|
|
self.match_window = None;
|
|
}
|
|
"width" => self.width = None,
|
|
"height" => self.height = None,
|
|
"refresh_hz" => self.refresh_hz = None,
|
|
"match_window" => self.match_window = None,
|
|
"bitrate_kbps" => self.bitrate_kbps = None,
|
|
"render_scale" => self.render_scale = None,
|
|
"codec" => self.codec = None,
|
|
"hdr_enabled" => self.hdr_enabled = None,
|
|
"enable_444" => self.enable_444 = None,
|
|
"compositor" => self.compositor = None,
|
|
"audio_channels" => self.audio_channels = None,
|
|
"mic_enabled" => self.mic_enabled = None,
|
|
"echo_cancel" => self.echo_cancel = None,
|
|
"touch_mode" => self.touch_mode = None,
|
|
"mouse_mode" => self.mouse_mode = None,
|
|
"invert_scroll" => self.invert_scroll = None,
|
|
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
|
|
"gamepad" => self.gamepad = None,
|
|
"gamepad_forwarding" => self.gamepad_forwarding = None,
|
|
"stats_verbosity" => self.stats_verbosity = None,
|
|
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
|
|
"present_priority" => self.present_priority = None,
|
|
"smooth_buffer" => self.smooth_buffer = None,
|
|
"vsync" => self.vsync = None,
|
|
"allow_vrr" => self.allow_vrr = None,
|
|
_ => return false,
|
|
}
|
|
true
|
|
}
|
|
|
|
/// 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 base = Settings {
|
|
width: 1920,
|
|
height: 1080,
|
|
bitrate_kbps: 20000,
|
|
codec: "hevc".into(),
|
|
..Default::default()
|
|
};
|
|
|
|
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!(
|
|
out.gamepad_forwarding,
|
|
"default on, and an empty overlay leaves it alone"
|
|
);
|
|
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),
|
|
echo_cancel: Some(false),
|
|
touch_mode: Some("pointer".into()),
|
|
mouse_mode: Some("desktop".into()),
|
|
invert_scroll: Some(true),
|
|
inhibit_shortcuts: Some(false),
|
|
gamepad: Some("dualsense".into()),
|
|
gamepad_forwarding: Some(false),
|
|
match_window: Some(true),
|
|
fullscreen_on_stream: Some(false),
|
|
stats_verbosity: Some(StatsVerbosity::Detailed),
|
|
present_priority: Some("smooth".into()),
|
|
smooth_buffer: Some(3),
|
|
vsync: Some(false),
|
|
allow_vrr: Some(false),
|
|
..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!(!out.echo_cancel);
|
|
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.gamepad_forwarding);
|
|
assert!(out.match_window);
|
|
assert!(!out.fullscreen_on_stream);
|
|
assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed);
|
|
assert_eq!(out.present_priority, "smooth");
|
|
assert_eq!(out.smooth_buffer, 3);
|
|
assert!(!out.vsync);
|
|
assert!(!out.allow_vrr);
|
|
// 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);
|
|
}
|
|
|
|
/// `absorb` records exactly the field a control changed, compares against the EFFECTIVE
|
|
/// settings (so a value equal to the global is still a pin), and never removes anything.
|
|
#[test]
|
|
fn absorb_records_the_touched_field_only() {
|
|
let base = Settings {
|
|
bitrate_kbps: 20000,
|
|
codec: "hevc".into(),
|
|
..Default::default()
|
|
};
|
|
let mut o = SettingsOverlay::default();
|
|
|
|
// One control fires: before = what it was showing, after = what the user picked.
|
|
let before = o.apply(&base);
|
|
let mut after = before.clone();
|
|
after.codec = "av1".into();
|
|
o.absorb(&before, &after);
|
|
assert_eq!(o.codec.as_deref(), Some("av1"));
|
|
assert_eq!(o.bitrate_kbps, None, "nothing else may be recorded");
|
|
|
|
// Setting it BACK to the global's value is still an override — the pin case. This is
|
|
// what makes absorb different from diffing against the globals at save time.
|
|
let before = o.apply(&base);
|
|
let mut after = before.clone();
|
|
after.codec = "hevc".into();
|
|
o.absorb(&before, &after);
|
|
assert_eq!(o.codec.as_deref(), Some("hevc"));
|
|
let mut moved = base.clone();
|
|
moved.codec = "h264".into();
|
|
assert_eq!(o.apply(&moved).codec, "hevc");
|
|
|
|
// The stats tier goes through the resolver, not the legacy bool.
|
|
let before = o.apply(&base);
|
|
let mut after = before.clone();
|
|
after.set_stats_verbosity(StatsVerbosity::Detailed);
|
|
o.absorb(&before, &after);
|
|
assert_eq!(o.stats_verbosity, Some(StatsVerbosity::Detailed));
|
|
|
|
// Identical snapshots record nothing.
|
|
let before = o.apply(&base);
|
|
let mut o2 = o.clone();
|
|
o2.absorb(&before, &before);
|
|
assert_eq!(o2, o);
|
|
}
|
|
|
|
/// `echo_cancel` is a first-class overlay field, not an `extra` passenger: it applies,
|
|
/// absorbs, clears, and serialises under the `echo_cancel` key the Apple and Android
|
|
/// clients write — one catalog has to round-trip through all three.
|
|
#[test]
|
|
fn echo_cancel_is_a_first_class_override() {
|
|
let base = Settings::default();
|
|
assert!(base.echo_cancel, "the setting ships on");
|
|
|
|
let mut o = SettingsOverlay::default();
|
|
let before = o.apply(&base);
|
|
let mut after = before.clone();
|
|
after.echo_cancel = false;
|
|
o.absorb(&before, &after);
|
|
assert_eq!(o.echo_cancel, Some(false));
|
|
assert!(!o.apply(&base).echo_cancel);
|
|
assert!(
|
|
o.extra.is_empty(),
|
|
"modelled fields must never land in the passthrough"
|
|
);
|
|
|
|
// Serialised under the shared key, and read back from a foreign client's file.
|
|
let text = serde_json::to_string(&o).unwrap();
|
|
assert!(text.contains("\"echo_cancel\":false"), "{text}");
|
|
let from_apple: SettingsOverlay =
|
|
serde_json::from_str(r#"{"mic_enabled":true,"echo_cancel":false}"#).unwrap();
|
|
assert_eq!(from_apple.echo_cancel, Some(false));
|
|
assert!(from_apple.extra.is_empty());
|
|
|
|
assert!(o.clear("echo_cancel"));
|
|
assert_eq!(o.echo_cancel, None);
|
|
assert!(o.is_empty());
|
|
}
|
|
|
|
/// The presentation cluster is first-class, not `extra` passengers: it applies,
|
|
/// absorbs, clears, and serialises under the exact keys the Apple client already
|
|
/// writes (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`) — one catalog
|
|
/// has to round-trip through every platform, and a mismatched key would be carried
|
|
/// but never applied.
|
|
#[test]
|
|
fn presentation_cluster_is_first_class() {
|
|
let base = Settings::default();
|
|
let mut o = SettingsOverlay::default();
|
|
let before = o.apply(&base);
|
|
let mut after = before.clone();
|
|
after.present_priority = "smooth".into();
|
|
o.absorb(&before, &after);
|
|
let before = o.apply(&base);
|
|
let mut after = before.clone();
|
|
after.smooth_buffer = 1;
|
|
o.absorb(&before, &after);
|
|
assert_eq!(o.present_priority.as_deref(), Some("smooth"));
|
|
assert_eq!(o.smooth_buffer, Some(1));
|
|
assert!(
|
|
o.extra.is_empty(),
|
|
"modelled fields must never land in the passthrough"
|
|
);
|
|
let out = o.apply(&base);
|
|
assert_eq!(
|
|
out.present_priority(),
|
|
crate::trust::PresentPriority::Smooth { buffer: 1 }
|
|
);
|
|
|
|
// Serialised under the shared keys, and read back from a foreign client's file.
|
|
let text = serde_json::to_string(&o).unwrap();
|
|
assert!(text.contains("\"present_priority\":\"smooth\""), "{text}");
|
|
assert!(text.contains("\"smooth_buffer\":1"), "{text}");
|
|
let from_apple: SettingsOverlay = serde_json::from_str(
|
|
r#"{"present_priority":"latency","smooth_buffer":2,"vsync":true,"allow_vrr":false}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(from_apple.present_priority.as_deref(), Some("latency"));
|
|
assert_eq!(from_apple.smooth_buffer, Some(2));
|
|
assert_eq!(from_apple.vsync, Some(true));
|
|
assert_eq!(from_apple.allow_vrr, Some(false));
|
|
assert!(from_apple.extra.is_empty());
|
|
|
|
assert!(o.clear("present_priority"));
|
|
assert!(o.clear("smooth_buffer"));
|
|
assert_eq!(o.present_priority, None);
|
|
assert!(o.is_empty());
|
|
let mut vrr = from_apple;
|
|
assert!(vrr.clear("vsync"));
|
|
assert!(vrr.clear("allow_vrr"));
|
|
assert_eq!((vrr.vsync, vrr.allow_vrr), (None, None));
|
|
}
|
|
|
|
/// `clear` is the explicit way back to inheriting, including the resolution tri-state.
|
|
#[test]
|
|
fn clear_drops_one_override() {
|
|
let mut o = SettingsOverlay {
|
|
width: Some(3840),
|
|
height: Some(2160),
|
|
match_window: Some(false),
|
|
codec: Some("av1".into()),
|
|
..Default::default()
|
|
};
|
|
assert!(o.clear("codec"));
|
|
assert_eq!(o.codec, None);
|
|
assert!(o.clear("resolution"));
|
|
assert_eq!((o.width, o.height, o.match_window), (None, None, None));
|
|
assert!(o.is_empty());
|
|
assert!(!o.clear("no_such_field"));
|
|
}
|
|
|
|
/// Controller forwarding defaults ON, so its interesting override is the FALSE one — and a
|
|
/// `false` that `apply` dropped would silently forward a pad the profile said not to.
|
|
/// `absorb` must record it, `clear` must undo it, and the serialized name both carry is the
|
|
/// one every client's reset button sends.
|
|
#[test]
|
|
fn gamepad_forwarding_overrides_off_and_resets_back() {
|
|
let base = Settings::default();
|
|
assert!(base.gamepad_forwarding, "the shipped default");
|
|
|
|
let mut o = SettingsOverlay::default();
|
|
let mut after = base.clone();
|
|
after.gamepad_forwarding = false;
|
|
o.absorb(&base, &after);
|
|
assert_eq!(o.gamepad_forwarding, Some(false));
|
|
assert!(!o.apply(&base).gamepad_forwarding);
|
|
|
|
assert!(o.clear("gamepad_forwarding"));
|
|
assert_eq!(o.gamepad_forwarding, None);
|
|
assert!(o.is_empty());
|
|
// Back to inheriting: the global's live value, not a remembered false.
|
|
assert!(o.apply(&base).gamepad_forwarding);
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
}
|