e4bdec97bd
apple / swift (push) Successful in 56s
android / android (push) Successful in 2m8s
audit / cargo-audit (push) Failing after 1m7s
ci / web (push) Successful in 32s
ci / docs-site (push) Successful in 30s
ci / bench (push) Successful in 1m32s
ci / rust (push) Failing after 3m31s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
flatpak / build-publish (push) Successful in 4m10s
deb / build-publish (push) Successful in 6m14s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m25s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m12s
docker / deploy-docs (push) Successful in 18s
Builds on the prior headless scaffold (which was committed but never VM-built — its audio.rs had two non-compiling wasapi calls). This makes the whole crate build + clippy + fmt + test green on x86_64-pc-windows-msvc and adds the windowed client. - Fix audio.rs: `DeviceEnumerator::new()?.get_default_device(...)` (the free fn doesn't exist) and the 3-arg `write_to_device` (wasapi 0.23). WASAPI shared-mode event-driven render + mic capture now compile and link. - present.rs: D3D11 renderer with WARP fallback (GPU-less dev box), runtime-compiled fullscreen-triangle shaders, dynamic RGBA video-texture upload, Contain-fit letterbox draw, and a flip-model swapchain on the window HWND. - app.rs: winit 0.30 ApplicationHandler — present loop + Moonlight-style click-to-capture input (keyboard via the physical-KeyCode→VK keymap, absolute mouse, wheel, F11), held state flushed on release/focus-loss. - keymap.rs: winit physical KeyCode → Windows VK (layout-independent positional mapping, the analogue of the Linux client's evdev table). - main.rs: windowed default + `--headless` counting mode, `--discover` (mDNS list), `--pair PIN` (SPAKE2 ceremony), `--pin HEX`/known-host/TOFU trust, settings-backed CLI defaults. UI decision: winit + raw D3D11 (the bootstrap doc's sanctioned fallback), confirmed by a research pass — windows-rs "Reactor" ships no SwapChainPanel / SetSwapChain escape hatch, so it can't host the presenter; winit+WARP validates on the GPU-less VM. Native-chrome host-list/settings GUI + D3D11VA hardware decode + 10-bit/HDR present are follow-ups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
171 lines
5.7 KiB
Rust
171 lines
5.7 KiB
Rust
//! Client identity, the known-hosts (pinned fingerprint) store, and app settings.
|
|
//!
|
|
//! Ported near-verbatim from the GTK Linux client; the only platform change is the config
|
|
//! directory — `%APPDATA%\punktfunk` (the Windows analogue of `~/.config/punktfunk`), shared
|
|
//! with the Windows host's identity location. The identity files (`client-{cert,key}.pem`)
|
|
//! keep the same names so the trust model is identical across the native clients.
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
use punktfunk_core::quic::endpoint;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
|
|
pub fn config_dir() -> Result<PathBuf> {
|
|
let appdata = std::env::var("APPDATA").context("APPDATA unset")?;
|
|
Ok(PathBuf::from(appdata).join("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)) {
|
|
return Ok((c, k));
|
|
}
|
|
let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?;
|
|
std::fs::create_dir_all(&dir)?;
|
|
std::fs::write(&cp, &c)?;
|
|
std::fs::write(&kp, &k)?;
|
|
tracing::info!(cert = %cp.display(), "generated client identity");
|
|
Ok((c, k))
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
#[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(())
|
|
}
|
|
|
|
// Used by the GUI host-list's pinned-fingerprint trust decision (the silent-reconnect
|
|
// path); the current CLI trust flow keys on address. Kept for parity with the other
|
|
// clients' known-hosts API — wired when the discovered-hosts UI lands.
|
|
#[allow(dead_code)]
|
|
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)
|
|
}
|
|
|
|
/// 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;
|
|
} else {
|
|
self.hosts.push(entry);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
/// Which host compositor backend to request (advisory; the host falls back to
|
|
/// auto-detect when unavailable).
|
|
pub compositor: String,
|
|
/// Grab system shortcuts (Alt+Tab, Win…) while input is captured.
|
|
pub inhibit_shortcuts: bool,
|
|
/// Stream the default microphone to the host's virtual mic source.
|
|
pub mic_enabled: bool,
|
|
}
|
|
|
|
impl Default for Settings {
|
|
fn default() -> Self {
|
|
Settings {
|
|
width: 0,
|
|
height: 0,
|
|
refresh_hz: 0,
|
|
bitrate_kbps: 0,
|
|
gamepad: "auto".into(),
|
|
compositor: "auto".into(),
|
|
inhibit_shortcuts: true,
|
|
mic_enabled: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Settings {
|
|
fn path() -> Result<PathBuf> {
|
|
Ok(config_dir()?.join("client-windows-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);
|
|
}
|
|
}
|
|
}
|