e707a962b6
Address findings from a repo security review:
- core: add a sliding-window anti-replay filter over the AEAD-authenticated
sequence in Session (poll_input/poll_frame), closing the input-replay gap the
data plane previously left to the LAN/VPN trust assumption. 4096-deep window,
unit-tested; the encrypted loopback suite confirms no false drops.
- clients: write the mTLS client private key 0600 and lock the config dir 0700
on Unix (it was world-readable at the umask default), re-locking existing
stores on load. pf-client-core::trust plus the probe's own identity writer.
Windows keeps the %APPDATA% ACL; Android/Apple already wrap the key.
- web: fix a post-login open redirect — resolve `next` via URL and require it to
stay same-origin, rejecting `/\evil.com` and tab/encoding variants the old
`!startsWith("//")` guard missed. Also fixes the dead safeNextPath helper.
- ci: SHA-256-pin the BtbN FFmpeg DLLs bundled into the signed Windows installer
(were fetched from the rolling `latest` tag unverified); fails closed on a
re-roll, matching the VB-CABLE gate.
- ci: fail-open fork-guard on the Windows/Apple host-mode PR build jobs that
share runner labels with the signing jobs. Definitive fix stays server-side
(Gitea outside-collaborator approval / isolated PR runners) — see the notes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
587 lines
24 KiB
Rust
587 lines
24 KiB
Rust
//! Client identity, the known-hosts (pinned fingerprint) store, and app settings.
|
|
//!
|
|
//! The identity shares `~/.config/punktfunk/client-{cert,key}.pem` (Linux; on Windows
|
|
//! `%APPDATA%\punktfunk`, the WinUI shell's directory) with `punktfunk-probe` so a box
|
|
//! pairs once whichever client it uses. On Windows the session binary reads the SAME
|
|
//! stores the WinUI shell writes — pairing there makes the session connect silently,
|
|
//! mirroring the GTK-shell arrangement on Linux. The WinUI shell re-exports THIS module
|
|
//! (`clients/windows/src/trust.rs`), so both processes share one `Settings` shape; the
|
|
//! 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 anyhow::{anyhow, Context, Result};
|
|
use punktfunk_core::client::NativeClient;
|
|
use punktfunk_core::quic::endpoint;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
|
|
pub fn config_dir() -> Result<PathBuf> {
|
|
#[cfg(windows)]
|
|
{
|
|
let appdata = std::env::var("APPDATA").context("APPDATA unset")?;
|
|
Ok(PathBuf::from(appdata).join("punktfunk"))
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
let home = std::env::var("HOME").context("HOME unset")?;
|
|
Ok(PathBuf::from(home).join(".config/punktfunk"))
|
|
}
|
|
}
|
|
|
|
/// This client's persistent identity, generated on first use — presented on every connect
|
|
/// so hosts can recognize it once paired.
|
|
pub fn load_or_create_identity() -> Result<(String, String)> {
|
|
let dir = config_dir()?;
|
|
let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem"));
|
|
if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) {
|
|
// An older build wrote the key with a plain `fs::write`, which honors the umask and
|
|
// typically lands 0644 — world-readable. Re-lock an existing store on load so upgrades
|
|
// get fixed, not just fresh installs. Best-effort (a read-only store keeps what it has).
|
|
#[cfg(unix)]
|
|
lock_identity_perms(&dir, &kp);
|
|
return Ok((c, k));
|
|
}
|
|
let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?;
|
|
std::fs::create_dir_all(&dir)?;
|
|
// The private key authorizes this client for full remote control of a paired host, so it must
|
|
// never be world-readable: lock the dir to the owner (0700) and create the key 0600 from the
|
|
// start (`fs::write` alone honors the umask → typically 0644). The certificate is public. On
|
|
// non-Unix the %APPDATA% profile ACL already scopes the dir to the user, so std perms suffice.
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
|
|
}
|
|
std::fs::write(&cp, &c)?;
|
|
write_private_key(&kp, k.as_bytes())?;
|
|
tracing::info!(cert = %cp.display(), "generated client identity");
|
|
Ok((c, k))
|
|
}
|
|
|
|
/// Write the client's mTLS private key owner-only. On Unix the file is created with mode 0600 from
|
|
/// the outset — an `fs::write` + later `chmod` would briefly expose it at the umask default. On
|
|
/// other platforms std's default perms plus the %APPDATA% profile ACL scope it to the user.
|
|
fn write_private_key(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::io::Write;
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
let mut f = std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(true)
|
|
.mode(0o600)
|
|
.open(path)?;
|
|
f.write_all(bytes)?;
|
|
}
|
|
#[cfg(not(unix))]
|
|
std::fs::write(path, bytes)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Best-effort re-lock of an already-present identity (dir 0700, key 0600) — for stores written by
|
|
/// an older build that left the key world-readable. Errors are ignored: the worst case is the
|
|
/// pre-existing perms, which this never loosens.
|
|
#[cfg(unix)]
|
|
fn lock_identity_perms(dir: &std::path::Path, key: &std::path::Path) {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
|
|
let _ = std::fs::set_permissions(key, std::fs::Permissions::from_mode(0o600));
|
|
}
|
|
|
|
pub fn hex(fp: &[u8; 32]) -> String {
|
|
fp.iter().map(|b| format!("{b:02x}")).collect()
|
|
}
|
|
|
|
pub fn parse_hex32(s: &str) -> Option<[u8; 32]> {
|
|
if s.len() != 64 {
|
|
return None;
|
|
}
|
|
let mut out = [0u8; 32];
|
|
for (i, b) in out.iter_mut().enumerate() {
|
|
*b = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
/// One trusted host: its pinned certificate fingerprint plus how we got there (TOFU or a
|
|
/// PIN ceremony) and where we last reached it.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct KnownHost {
|
|
pub name: String,
|
|
pub addr: String,
|
|
pub port: u16,
|
|
/// SHA-256 of the host certificate, lowercase hex — the pin for every later connect.
|
|
pub fp_hex: String,
|
|
/// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use).
|
|
pub paired: bool,
|
|
/// Unix seconds of the last successful connect — the hosts page marks the
|
|
/// most-recent card with the accent bar. `default` so pre-existing stores load.
|
|
#[serde(default)]
|
|
pub last_used: Option<u64>,
|
|
/// Wake-on-LAN MAC(s) (`aa:bb:cc:dd:ee:ff`) learned from the host's mDNS `mac` TXT while it
|
|
/// was online, so we can wake it once it sleeps and stops advertising. `default` so
|
|
/// pre-existing stores load; empty until first learned.
|
|
#[serde(default)]
|
|
pub mac: Vec<String>,
|
|
}
|
|
|
|
#[derive(Default, Serialize, Deserialize)]
|
|
pub struct KnownHosts {
|
|
pub hosts: Vec<KnownHost>,
|
|
}
|
|
|
|
impl KnownHosts {
|
|
fn path() -> Result<PathBuf> {
|
|
Ok(config_dir()?.join("client-known-hosts.json"))
|
|
}
|
|
|
|
pub fn load() -> 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()
|
|
}
|
|
|
|
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)?)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn find_by_fp(&self, fp_hex: &str) -> Option<&KnownHost> {
|
|
self.hosts.iter().find(|h| h.fp_hex == fp_hex)
|
|
}
|
|
|
|
pub fn find_by_addr(&self, addr: &str, port: u16) -> Option<&KnownHost> {
|
|
self.hosts.iter().find(|h| h.addr == addr && h.port == port)
|
|
}
|
|
|
|
/// Forget the entry with this fingerprint. Returns true if one was removed (the user
|
|
/// will have to pair/trust again to reconnect).
|
|
pub fn remove_by_fp(&mut self, fp_hex: &str) -> bool {
|
|
let before = self.hosts.len();
|
|
self.hosts.retain(|h| h.fp_hex != fp_hex);
|
|
self.hosts.len() != before
|
|
}
|
|
|
|
/// Insert or refresh an entry, keyed by fingerprint. `paired` only ever upgrades
|
|
/// (a later TOFU connect must not demote a PIN-paired host).
|
|
pub fn upsert(&mut self, entry: KnownHost) {
|
|
if let Some(h) = self.hosts.iter_mut().find(|h| h.fp_hex == entry.fp_hex) {
|
|
h.name = entry.name;
|
|
h.addr = entry.addr;
|
|
h.port = entry.port;
|
|
h.paired |= entry.paired;
|
|
// A refresh without a timestamp must not erase the stored one.
|
|
if entry.last_used.is_some() {
|
|
h.last_used = entry.last_used;
|
|
}
|
|
// Likewise a trust-decision upsert (which carries no MAC) must not wipe learned MACs.
|
|
if !entry.mac.is_empty() {
|
|
h.mac = entry.mac;
|
|
}
|
|
} else {
|
|
self.hosts.push(entry);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Load-upsert-save in one step — the pin every trust decision (TOFU accept, PIN
|
|
/// 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();
|
|
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(),
|
|
});
|
|
let _ = known.save();
|
|
}
|
|
|
|
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
|
|
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
|
|
/// the hosts page can call it on every discovery tick without churning the store.
|
|
pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
|
|
if mac.is_empty() {
|
|
return;
|
|
}
|
|
let mut known = KnownHosts::load();
|
|
let Some(h) = known
|
|
.hosts
|
|
.iter_mut()
|
|
.find(|h| (!fp_hex.is_empty() && h.fp_hex == fp_hex) || (h.addr == addr && h.port == port))
|
|
else {
|
|
return;
|
|
};
|
|
if h.mac == mac {
|
|
return;
|
|
}
|
|
h.mac = mac.to_vec();
|
|
let _ = known.save();
|
|
}
|
|
|
|
/// Re-key a saved host's address/port after it rediscovered on a new DHCP lease (matched by
|
|
/// fingerprint). No-op — and no disk write — when unchanged. Called from the wake-and-wait flow when
|
|
/// a woken host reappears on a different IP than the stored one, so this and future connects dial the
|
|
/// live address instead of the stale one.
|
|
pub fn rekey_addr(fp_hex: &str, addr: &str, port: u16) {
|
|
if fp_hex.is_empty() {
|
|
return;
|
|
}
|
|
let mut known = KnownHosts::load();
|
|
let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp_hex) else {
|
|
return;
|
|
};
|
|
if h.addr == addr && h.port == port {
|
|
return;
|
|
}
|
|
h.addr = addr.to_string();
|
|
h.port = port;
|
|
let _ = known.save();
|
|
}
|
|
|
|
/// Stamp "now" as this host's last successful connect (drives the hosts page's
|
|
/// most-recent accent). No-op when the fingerprint isn't stored.
|
|
pub fn touch_last_used(fp_hex: &str) {
|
|
let mut known = KnownHosts::load();
|
|
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp_hex) {
|
|
h.last_used = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.ok();
|
|
let _ = known.save();
|
|
}
|
|
}
|
|
|
|
/// Run the SPAKE2 PIN ceremony against a host. `device_name` is the label the HOST
|
|
/// stores this client under (its paired-devices list); the 90 s budget covers a
|
|
/// human-typed PIN. Returns the host's now-verified certificate fingerprint to pin.
|
|
pub fn pair_with_host(
|
|
addr: &str,
|
|
port: u16,
|
|
identity: &(String, String),
|
|
pin: &str,
|
|
device_name: &str,
|
|
) -> std::result::Result<[u8; 32], punktfunk_core::PunktfunkError> {
|
|
NativeClient::pair(
|
|
addr,
|
|
port,
|
|
(&identity.0, &identity.1),
|
|
pin.trim(),
|
|
device_name,
|
|
std::time::Duration::from_secs(90),
|
|
)
|
|
}
|
|
|
|
/// Probe several hosts for reachability in parallel — one thread each, so the wall-clock cost is
|
|
/// ~one `timeout`, not the sum. Each element of the returned vec corresponds by index to
|
|
/// `targets`. Wraps the single-host [`NativeClient::probe`] (a bounded, trust-agnostic,
|
|
/// mDNS-independent QUIC handshake); used by the hosts page's presence pips and the headless
|
|
/// `--list-hosts --probe`.
|
|
pub fn probe_reachable_many(
|
|
targets: Vec<(String, u16)>,
|
|
timeout: std::time::Duration,
|
|
) -> Vec<bool> {
|
|
let handles: Vec<_> = targets
|
|
.into_iter()
|
|
.map(|(addr, port)| std::thread::spawn(move || NativeClient::probe(&addr, port, timeout)))
|
|
.collect();
|
|
handles
|
|
.into_iter()
|
|
.map(|h| h.join().unwrap_or(false))
|
|
.collect()
|
|
}
|
|
|
|
/// How much the on-stream statistics overlay shows — the Android client's tiers, shared
|
|
/// across every client (design/stats-unification.md): each tier is a strict superset of
|
|
/// the previous. Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed live.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum StatsVerbosity {
|
|
Off,
|
|
/// One glanceable line: fps · end-to-end ms · Mb/s.
|
|
Compact,
|
|
/// Stream mode plus the end-to-end latency percentiles and loss counters.
|
|
Normal,
|
|
/// Everything: decoder path, HDR tags, and the per-stage latency equation.
|
|
Detailed,
|
|
}
|
|
|
|
impl StatsVerbosity {
|
|
/// Cycle order (also the settings pickers' option order).
|
|
pub const ALL: [StatsVerbosity; 4] = [
|
|
StatsVerbosity::Off,
|
|
StatsVerbosity::Compact,
|
|
StatsVerbosity::Normal,
|
|
StatsVerbosity::Detailed,
|
|
];
|
|
|
|
/// The next tier in the live cycle, wrapping back to Off.
|
|
pub fn next(self) -> StatsVerbosity {
|
|
match self {
|
|
StatsVerbosity::Off => StatsVerbosity::Compact,
|
|
StatsVerbosity::Compact => StatsVerbosity::Normal,
|
|
StatsVerbosity::Normal => StatsVerbosity::Detailed,
|
|
StatsVerbosity::Detailed => StatsVerbosity::Off,
|
|
}
|
|
}
|
|
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
StatsVerbosity::Off => "Off",
|
|
StatsVerbosity::Compact => "Compact",
|
|
StatsVerbosity::Normal => "Normal",
|
|
StatsVerbosity::Detailed => "Detailed",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
|
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Settings {
|
|
/// Stream mode; `0` = the native size/refresh of the monitor the window is on,
|
|
/// resolved at connect time.
|
|
pub width: u32,
|
|
pub height: u32,
|
|
pub refresh_hz: u32,
|
|
/// Requested encoder bitrate (kbps); 0 = host default.
|
|
pub bitrate_kbps: u32,
|
|
pub gamepad: String,
|
|
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
|
|
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
|
|
/// gamepad service at startup so the choice survives restarts.
|
|
pub forward_pad: String,
|
|
/// Which host compositor backend to request (advisory; the host falls back to
|
|
/// auto-detect when unavailable).
|
|
pub compositor: String,
|
|
/// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured.
|
|
pub inhibit_shortcuts: bool,
|
|
/// Stream the default microphone to the host's virtual mic source.
|
|
pub mic_enabled: bool,
|
|
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
|
/// can capture; the resolved count drives the decoder + playback layout.
|
|
pub audio_channels: u8,
|
|
/// Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, or `"av1"`. A soft
|
|
/// preference — the host honors it when it can emit it, else falls back to the best shared codec.
|
|
#[serde(default = "default_codec")]
|
|
pub codec: String,
|
|
/// Video decoder preference: `"auto"` (Vulkan Video → VAAPI → software),
|
|
/// `"vulkan"`, `"vaapi"`, `"software"`.
|
|
/// The `PUNKTFUNK_DECODER` env var overrides this (see `video::Decoder::new`).
|
|
pub decoder: String,
|
|
/// Decode/present GPU (multi-GPU boxes): the adapter's marketing name, as the WinUI
|
|
/// shell's GPU picker stores it; empty = automatic. The session maps it onto the
|
|
/// presenter's device pick (`PUNKTFUNK_VK_ADAPTER`). `default` so pre-existing
|
|
/// stores (and the Linux shells, which have no picker yet) load.
|
|
#[serde(default)]
|
|
pub adapter: String,
|
|
/// Advertise 10-bit + HDR10 so the host upgrades HDR content to a Main10/PQ stream.
|
|
/// The presenter handles the display side dynamically either way (HDR10 swapchain
|
|
/// where offered, tonemap where not) — off means "never send me 10-bit".
|
|
/// `default = true`: the Linux stores never carried this and always advertised.
|
|
#[serde(default = "default_true")]
|
|
pub hdr_enabled: bool,
|
|
/// Legacy on/off for the stats overlay — superseded by `stats_verbosity` but kept
|
|
/// written in sync (`set_stats_verbosity`) so pre-tier binaries reading the same
|
|
/// file keep working. `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted
|
|
/// this as `show_hud`.
|
|
#[serde(alias = "show_hud")]
|
|
pub show_stats: bool,
|
|
/// Stats overlay tier. `None` = a pre-tier store; resolve through
|
|
/// [`Settings::stats_verbosity`], which falls back to `show_stats`.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub stats_verbosity: Option<StatsVerbosity>,
|
|
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
|
|
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
|
|
pub fullscreen_on_stream: bool,
|
|
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
|
|
/// mirrors the Apple client's "Show game library" toggle, default off.
|
|
pub library_enabled: bool,
|
|
}
|
|
|
|
fn default_codec() -> String {
|
|
"auto".into()
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
impl Settings {
|
|
/// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false`
|
|
/// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed).
|
|
pub fn stats_verbosity(&self) -> StatsVerbosity {
|
|
self.stats_verbosity.unwrap_or(if self.show_stats {
|
|
StatsVerbosity::Normal
|
|
} else {
|
|
StatsVerbosity::Off
|
|
})
|
|
}
|
|
|
|
/// Set the tier, keeping the legacy `show_stats` bool coherent for pre-tier
|
|
/// binaries that read the same settings file.
|
|
pub fn set_stats_verbosity(&mut self, v: StatsVerbosity) {
|
|
self.stats_verbosity = Some(v);
|
|
self.show_stats = v != StatsVerbosity::Off;
|
|
}
|
|
|
|
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
|
pub fn preferred_codec(&self) -> u8 {
|
|
match self.codec.as_str() {
|
|
"h264" | "avc" => punktfunk_core::quic::CODEC_H264,
|
|
"hevc" | "h265" => punktfunk_core::quic::CODEC_HEVC,
|
|
"av1" => punktfunk_core::quic::CODEC_AV1,
|
|
_ => 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for Settings {
|
|
fn default() -> Self {
|
|
Settings {
|
|
width: 0,
|
|
height: 0,
|
|
refresh_hz: 0,
|
|
bitrate_kbps: 0,
|
|
gamepad: "auto".into(),
|
|
forward_pad: String::new(),
|
|
compositor: "auto".into(),
|
|
inhibit_shortcuts: true,
|
|
mic_enabled: false,
|
|
audio_channels: 2,
|
|
codec: "auto".into(),
|
|
decoder: "auto".into(),
|
|
adapter: String::new(),
|
|
hdr_enabled: true,
|
|
show_stats: true,
|
|
stats_verbosity: None,
|
|
fullscreen_on_stream: true,
|
|
library_enabled: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Settings {
|
|
fn path() -> Result<PathBuf> {
|
|
// The shell's settings file on each OS: the GTK shell's on Linux, the WinUI
|
|
// shell's on Windows. The desktop shells AND the session binary's console
|
|
// settings screen write it (load-modify-save per change — Gaming Mode has no
|
|
// other editor); a plain `--connect` stream only ever reads.
|
|
#[cfg(windows)]
|
|
return Ok(config_dir()?.join("client-windows-settings.json"));
|
|
#[cfg(not(windows))]
|
|
Ok(config_dir()?.join("client-gtk-settings.json"))
|
|
}
|
|
|
|
pub fn load() -> Settings {
|
|
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()
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic.
|
|
#[test]
|
|
fn settings_forward_pad_defaults_empty() {
|
|
let old = r#"{"width":1280,"height":720,"refresh_hz":60,"bitrate_kbps":0,
|
|
"gamepad":"auto","compositor":"auto","inhibit_shortcuts":true,"mic_enabled":true}"#;
|
|
let s: Settings = serde_json::from_str(old).unwrap();
|
|
assert_eq!(s.forward_pad, "");
|
|
let round: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
|
assert_eq!(round.forward_pad, "");
|
|
}
|
|
|
|
/// A pre-unification WinUI shell settings file (≤ 0.8.4, when the shell had its own
|
|
/// `Settings` struct) still loads: `show_hud` migrates onto `show_stats` via the serde
|
|
/// alias, the dropped `engine` knob is ignored, fields that file never carried
|
|
/// (forward_pad, fullscreen_on_stream, …) default, and the D3D11VA-era
|
|
/// `decoder: "hardware"` survives as-is (video::Decoder::new reads it as auto).
|
|
#[test]
|
|
fn settings_reads_winui_shell_shape() {
|
|
let shell = r#"{
|
|
"width": 2560, "height": 1440, "refresh_hz": 120, "bitrate_kbps": 20000,
|
|
"gamepad": "dualsense", "compositor": "auto",
|
|
"inhibit_shortcuts": true, "mic_enabled": true, "audio_channels": 6,
|
|
"hdr_enabled": true, "decoder": "hardware", "codec": "av1",
|
|
"adapter": "NVIDIA GeForce RTX 4080", "show_hud": false, "engine": "builtin"
|
|
}"#;
|
|
let s: Settings = serde_json::from_str(shell).unwrap();
|
|
assert_eq!((s.width, s.height, s.refresh_hz), (2560, 1440, 120));
|
|
assert_eq!(s.bitrate_kbps, 20000);
|
|
assert_eq!(s.audio_channels, 6);
|
|
assert!(s.mic_enabled);
|
|
assert_eq!(s.decoder, "hardware");
|
|
assert_eq!(s.preferred_codec(), punktfunk_core::quic::CODEC_AV1);
|
|
assert_eq!(s.adapter, "NVIDIA GeForce RTX 4080");
|
|
assert!(s.hdr_enabled);
|
|
// The old shell's `show_hud` lands on `show_stats` (the user's preference survives).
|
|
assert!(!s.show_stats);
|
|
// Fields the old file doesn't carry take this struct's defaults.
|
|
assert_eq!(s.forward_pad, "");
|
|
assert!(s.fullscreen_on_stream);
|
|
assert!(!s.library_enabled);
|
|
}
|
|
|
|
/// Stats-tier resolution: a pre-tier store falls back to `show_stats` (off → Off,
|
|
/// on/absent → Normal), an explicit tier wins, and setting a tier keeps the legacy
|
|
/// bool in sync so pre-tier binaries reading the same file agree on off vs on.
|
|
#[test]
|
|
fn stats_verbosity_migrates_and_round_trips() {
|
|
let mut s: Settings = serde_json::from_str("{}").unwrap();
|
|
assert_eq!(s.stats_verbosity(), StatsVerbosity::Normal);
|
|
let off: Settings = serde_json::from_str(r#"{"show_stats":false}"#).unwrap();
|
|
assert_eq!(off.stats_verbosity(), StatsVerbosity::Off);
|
|
|
|
s.set_stats_verbosity(StatsVerbosity::Compact);
|
|
assert!(s.show_stats);
|
|
s.set_stats_verbosity(StatsVerbosity::Off);
|
|
assert!(!s.show_stats);
|
|
|
|
s.set_stats_verbosity(StatsVerbosity::Detailed);
|
|
let round: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
|
assert_eq!(round.stats_verbosity(), StatsVerbosity::Detailed);
|
|
// The tier serializes lowercase — the file stays human-readable.
|
|
assert!(serde_json::to_string(&s).unwrap().contains("\"detailed\""));
|
|
}
|
|
|
|
/// The WinUI shell's known-hosts shape (no `last_used` field) loads losslessly — same
|
|
/// filename, same directory, so on Windows the two clients genuinely share the store.
|
|
#[test]
|
|
fn known_hosts_reads_winui_shell_shape() {
|
|
let shell = r#"{"hosts":[{
|
|
"name": "Gaming PC", "addr": "192.168.1.50", "port": 9777,
|
|
"fp_hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
"paired": true, "mac": ["aa:bb:cc:dd:ee:ff"]
|
|
}]}"#;
|
|
let k: KnownHosts = serde_json::from_str(shell).unwrap();
|
|
let h = k.find_by_addr("192.168.1.50", 9777).unwrap();
|
|
assert!(h.paired);
|
|
assert_eq!(h.last_used, None);
|
|
assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]);
|
|
assert!(parse_hex32(&h.fp_hex).is_some());
|
|
}
|
|
}
|