2508b72062
Two problems, one feature. The gamepad/couch UI existed but you had to
already know it was there:
- On the hosts page its card only rendered when a controller was CONNECTED,
so with no pad plugged in there was no visible entry point at all -- the
only other door being a per-host overflow menu behind a "..." nobody
opens. The card now always shows, with copy that adapts to whether a pad
is present, so the feature is findable before you own the hardware.
- There was no way to start it directly. An HTPC or TV box wants the couch
interface as its first screen, not the desktop shell. "punktfunk-client
--console" now hands straight off to it (fullscreen unless --windowed),
which covers shortcuts, Steam entries, autostart and Task Scheduler.
The Start-menu tile needs its own executable: an MSIX <Application> cannot
pass arguments to a full-trust exe, so "punktfunk-client.exe --console" is
not expressible there. punktfunk-console.exe is a ~20-line hand-off to the
session binary's browse mode, staged into the package and given its own
tile ("Punktfunk Console").
Nothing new is implemented behind either door: a bare "--browse" was
already a complete standalone client (host list, discovery, PIN pairing,
settings, Wake-on-LAN, library). It just had no front door.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
233 lines
9.7 KiB
Rust
233 lines
9.7 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)
|
|
|
|
// 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;
|
|
#[cfg(windows)]
|
|
mod discovery;
|
|
#[cfg(windows)]
|
|
mod gpu;
|
|
#[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.
|
|
unsafe {
|
|
use windows::Win32::System::Console::{AttachConsole, ATTACH_PARENT_PROCESS};
|
|
let _ = AttachConsole(ATTACH_PARENT_PROCESS);
|
|
}
|
|
set_app_user_model_id();
|
|
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
|
)
|
|
.init();
|
|
|
|
let args: Vec<String> = std::env::args().collect();
|
|
let flag = |name: &str| args.iter().any(|a| a == name);
|
|
|
|
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");
|
|
}
|
|
match cmd.status() {
|
|
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();
|
|
if let Err(e) = app::run(identity, gamepad) {
|
|
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::Foundation::APPMODEL_ERROR_NO_PACKAGE;
|
|
use windows::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
|
|
use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
|
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 = 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);
|
|
}
|