Files
punktfunk/clients/windows/src/main.rs
T
enricobuehler b0ec759fad fix(brand): capitalize "Punktfunk" in user-facing text
The docs site's page title read "punktfunk docs". Fixed that and swept the
rest of the tree for the same defect, capitalizing the brand wherever it is
shown to a human and leaving it lowercase where it is a technical identifier
(CLI/package names, `punktfunk://` scheme, PnP enumerator, TLS SNI, logcat
tag, config paths, CMS tenant id).

User-visible fixes:

- docs-site: page title -> "Punktfunk Docs"; API reference title, meta
  description and the branded bar's aria-label; the BrandMark/Wordmark SVG
  accessible names (the web console already had these capitalized -- the
  docs site had drifted from it).
- Android: six strings of live UI copy -- the local-network permission
  dialog (x2), the connect-screen error banner, and the no-controller
  explainer.
- Apple: the "No Hosts" empty-state text and the fallback display name for
  a host that advertises no instance name.
- Windows client: the `--discover` progress line.
- KWin fake-input: the application name passed to `authenticate()` (the
  grant is cached per-exe, so the string is display-only).
- THIRD-PARTY-NOTICES: fixed in both generators (about.hbs and
  gen-third-party-notices.py) and applied to the three checked-in outputs
  so they match what a regeneration now produces.

Every changed line differs from the original only by letter case, so line
lengths are unchanged and no formatter width rule is affected.
`cargo fmt --all --check` passes.
2026-08-06 23:10:25 +02:00

286 lines
12 KiB
Rust

//! `punktfunk-client` — the native Windows punktfunk/1 client.
//!
//! Pure Rust: `NativeClient` linked as a crate (no C ABI, like the GTK Linux client) · SDL3
//! gamepads · a **WinUI 3** shell (windows-reactor). Streaming (decode + present + audio) runs in
//! the spawned `punktfunk-session` Vulkan binary; the shell owns host selection, trust and
//! pairing. The trust surface mirrors the
//! other native clients: persistent identity, trust-on-first-use, SPAKE2 PIN pairing — all in-app
//! (host list, settings, pairing). Streaming runs in the spawned `punktfunk-session` binary;
//! `--headless --speed-test` keeps a decode-less CLI measurement path.
//!
//! Usage:
//! punktfunk-client (open the WinUI 3 window: host list, settings, pairing)
//! punktfunk-client --discover (list punktfunk hosts on the LAN)
//! punktfunk-client --headless --speed-test --connect host[:port]
//! (measure the path: probe burst → goodput / loss / recommended bitrate)
// Unsafe-proof program: every `unsafe {}` in this client carries a `// SAFETY:` proof.
#![deny(clippy::undocumented_unsafe_blocks)]
// Link as a GUI (windows) subsystem binary so the default windowed launch (MSIX / double-click)
// does NOT pop a console window. The CLI paths (--headless/--discover) reattach to the launching
// terminal's console at startup (see main), so their output is still visible when run from a shell.
#![cfg_attr(windows, windows_subsystem = "windows")]
#[cfg(windows)]
mod app;
// `punktfunk://` activation: single instance, hand-off, and the positional URL parse
// (design/client-deep-links.md §4.2).
#[cfg(windows)]
mod deeplink;
#[cfg(windows)]
mod discovery;
#[cfg(windows)]
mod gpu;
#[cfg(windows)]
mod logfile;
#[cfg(windows)]
mod probe;
#[cfg(windows)]
mod shell_window;
#[cfg(windows)]
mod spawn;
#[cfg(windows)]
mod trust;
#[cfg(windows)]
mod wol;
#[cfg(windows)]
fn main() {
// With #![windows_subsystem = "windows"] the process starts with no console, so the GUI/MSIX
// launch is window-free. AttachConsole only binds to an ALREADY-EXISTING parent console (it
// never creates one), so when launched from a terminal — `--headless`/`--discover` — stdout and
// the tracing writer below land in that terminal; from Explorer/MSIX it's a harmless no-op.
// SAFETY: `AttachConsole` takes a plain process-id constant and binds to an already-existing
// parent console; it allocates nothing and dereferences no caller memory, and failure (no parent
// console) is ignored by design.
unsafe {
use windows::Win32::consoleapi::{AttachConsole, ATTACH_PARENT_PROCESS};
let _ = AttachConsole(ATTACH_PARENT_PROCESS);
}
set_app_user_model_id();
// Everything logs to stderr AND `%LOCALAPPDATA%\punktfunk\logs\client.log` (see [`logfile`]):
// a GUI/MSIX launch has no console, so without the file the client side of any field report
// simply doesn't exist. ANSI off — the file is what users send, keep it grep-clean.
logfile::init();
tracing_subscriber::fmt()
.with_ansi(false)
.with_writer(logfile::tee)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
if let Some(p) = logfile::path() {
tracing::info!(path = %p.display(), "client log file (rotated at 10 MB, one .old kept)");
}
let args: Vec<String> = std::env::args().collect();
let flag = |name: &str| args.iter().any(|a| a == name);
// A `punktfunk://` link — from protocol activation, `start`, or a shortcut. If a shell is
// already running, hand it over and get out of the way: one window, and the link opens
// where the user's hosts already are. A hand-off that finds nobody falls through and this
// process becomes the shell that opens it, so the link is never simply lost.
let link = deeplink::positional_url(&args);
if let Some(url) = &link {
if !deeplink::claim_primary() && deeplink::forward_to_primary(url) {
return;
}
}
if flag("--discover") {
discover_and_print();
return;
}
let identity = match trust::load_or_create_identity() {
Ok(i) => i,
Err(e) => {
eprintln!("client identity: {e:#}");
std::process::exit(1);
}
};
if flag("--headless") {
run_headless_cli(&args, identity);
return;
}
// `--console`: go straight to the gamepad/couch UI, skipping the WinUI shell entirely —
// the HTPC entry point (a Start-menu tile, a Steam shortcut, a startup item). The session
// binary's bare `--browse` IS a complete standalone client: host list, discovery, PIN
// pairing, settings and Wake-on-LAN, all controller-driven. We just exec it and mirror
// its exit code, so anything supervising this process sees the real result.
if flag("--console") {
let mut cmd = std::process::Command::new(spawn::session_binary());
cmd.arg("--browse");
// A couch UI is fullscreen unless explicitly told otherwise.
if !flag("--windowed") {
cmd.arg("--fullscreen");
}
// Spawn (not `status()`) so the session's stderr rides the log tee — a couch launch
// (Start-menu tile, Steam shortcut) has no console to inherit either.
cmd.stderr(std::process::Stdio::piped());
let run = cmd.spawn().and_then(|mut child| {
if let Some(stderr) = child.stderr.take() {
logfile::forward_child_stderr(stderr);
}
child.wait()
});
match run {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(e) => {
eprintln!("could not start the console UI: {e}");
std::process::exit(1);
}
}
}
// Windowed (default): the WinUI 3 app owns host selection, settings, and pairing.
// Framework-dependent deployment: initialize the Windows App SDK runtime before any WinUI
// call (build.rs stages the bootstrap DLL via windows-reactor-setup).
if let Err(e) = windows_reactor::bootstrap() {
tracing::error!(error = %e, "Windows App SDK bootstrap failed");
std::process::exit(1);
}
// The shared SDL gamepad service (pf-client-core). The shell only enumerates pads (Settings
// list) and persists the pin; the spawned punktfunk-session runs the SAME service and does the
// actual forwarding — so, unlike the old shell fork, we never `attach()` here. Idle it stays
// hands-off the hardware (id-getter metadata, no device open, Valve HIDAPI drivers off).
let gamepad = pf_client_core::gamepad::GamepadService::start();
// From here this process IS the shell: queue the link it was launched with (the router
// picks it up once the window is live) and start listening for links from later instances.
if let Some(url) = link {
deeplink::queue(url);
}
deeplink::install_receiver();
let outcome = app::run(identity, gamepad);
// Hand the single-instance mutex back before exiting, so the next launch claims it as
// primary immediately rather than waiting for the kernel to notice this process is gone.
deeplink::release_primary();
if let Err(e) = outcome {
tracing::error!(error = %e, "WinUI app failed");
std::process::exit(1);
}
}
/// Tag unpackaged (dev) runs with the explicit AppUserModelID that `pf-presenter`'s
/// session window also adopts, so the shell and the stream window group as ONE taskbar
/// app across the shell⇄session visibility handoff. MSIX runs already carry the package
/// identity — overriding it would detach the window from the Start-menu pin, so packaged
/// processes are left alone. Must run before any window exists.
#[cfg(windows)]
fn set_app_user_model_id() {
use windows::Win32::appmodel::GetCurrentPackageFullName;
use windows::Win32::shobjidl_core::SetCurrentProcessExplicitAppUserModelID;
use windows::Win32::winerror::APPMODEL_ERROR_NO_PACKAGE;
// SAFETY: `GetCurrentPackageFullName` is called with `len = 0` and no buffer, which is the
// documented identity PROBE — it writes nothing and only reports whether this process is
// packaged; `SetCurrentProcessExplicitAppUserModelID` takes a static wide literal.
unsafe {
let mut len: u32 = 0;
// No buffer: just probe whether the process has package identity.
if GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE {
return; // packaged (or indeterminate) — leave the identity alone
}
// Must stay in sync with pf-presenter's win32.rs, or the windows stop grouping.
let _ = SetCurrentProcessExplicitAppUserModelID(windows::core::w!("unom.punktfunk.client"));
}
}
/// `--headless --speed-test --connect host[:port]`: measure the path over the real data plane and
/// print the outcome — the Windows analogue of `punktfunk-probe`. The former in-process
/// frame-count connect path went with the legacy builtin stream; real streaming is windowed-only.
#[cfg(windows)]
fn run_headless_cli(args: &[String], identity: (String, String)) {
let arg = |name: &str| -> Option<String> {
args.iter()
.position(|a| a == name)
.and_then(|i| args.get(i + 1))
.cloned()
};
let flag = |name: &str| args.iter().any(|a| a == name);
let Some(target) = arg("--connect") else {
eprintln!("--headless requires --connect host[:port]");
std::process::exit(2);
};
let (host, port) = match target.rsplit_once(':') {
Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)),
None => (target.clone(), 9777u16),
};
// Speed test: measure the path over the real data plane, print the outcome, exit. The saved
// fingerprint for this address (if any) pins the connect, like the GUI's per-host test.
if flag("--speed-test") {
let fp = trust::KnownHosts::load()
.find_by_addr(&host, port)
.map(|k| k.fp_hex.clone());
match probe::run_speed_probe(&host, port, fp.as_deref(), identity) {
Ok(r) => {
let mbps = f64::from(r.throughput_kbps) / 1000.0;
let recommended = f64::from(r.throughput_kbps / 10 * 7) / 1000.0;
println!(
"{mbps:.0} Mbit/s measured · {:.1} % loss · recommended bitrate {recommended:.0} Mbit/s (--bitrate {:.0})",
r.loss_pct,
recommended
);
}
Err(e) => {
eprintln!("speed test failed: {e}");
std::process::exit(1);
}
}
return;
}
// Only --speed-test remains headless: real streaming runs in the windowed app's spawned
// punktfunk-session binary, which the deleted in-process frame-count path was replaced by.
eprintln!("--headless supports only --speed-test now \u{2014} run the windowed app to stream");
std::process::exit(2);
}
/// `--discover`: browse the LAN for punktfunk hosts (mDNS) and print them, then exit.
#[cfg(windows)]
fn discover_and_print() {
use std::time::{Duration, Instant};
println!("Browsing the LAN for Punktfunk hosts (~5 s)…");
let (rx, _rescan) = discovery::browse();
let deadline = Instant::now() + Duration::from_secs(5);
let mut seen = std::collections::HashSet::new();
while Instant::now() < deadline {
while let Ok(h) = rx.try_recv() {
if seen.insert(h.key.clone()) {
println!(
" {} {}:{} pair={} fp={}",
h.name,
h.addr,
h.port,
if h.pair.is_empty() {
"optional"
} else {
&h.pair
},
if h.fp_hex.is_empty() { "-" } else { &h.fp_hex },
);
}
}
std::thread::sleep(Duration::from_millis(100));
}
if seen.is_empty() {
println!(" (none found — is a host running with --native / punktfunk1-host?)");
}
}
/// WinUI 3 / Direct3D11 / WASAPI / SDL3 are Windows turf; this stub keeps `cargo build
/// --workspace` green on Linux/macOS (the other native clients live in
/// clients/linux and clients/apple).
#[cfg(not(windows))]
fn main() {
eprintln!(
"punktfunk-client-windows is Windows-only — the Linux client lives in \
clients/linux, the macOS client in clients/apple"
);
std::process::exit(2);
}