//! 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 { #[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, /// 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, } #[derive(Default, Serialize, Deserialize)] pub struct KnownHosts { pub hosts: Vec, } impl KnownHosts { fn path() -> Result { 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 { 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, /// 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 { // 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()); } }