windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
WP4 / D2. Protocol activation of a full-trust packaged app delivers the URI as the command line, so a browser prompt, `start punktfunk://…` and a written `.lnk` all arrive the same way — as a positional argument, the same door the Linux shell uses. What Windows does not give us is single-instancing: unlike GApplication, a second activation is simply a second process. So the first instance claims the named mutex `unom.punktfunk.client` and any later one hands its URL to the winner over `WM_COPYDATA` and exits — one window, and the link opens where the user's hosts already are. The hand-off retries while the primary's window is still coming up (a shortcut double-clicked during startup is the ordinary case), and a hand-off that finds nobody falls through so this process becomes the shell that opens it. A link is never silently dropped, which is also why the inbox is a queue: two shortcuts in quick succession are two links. Routing is the same four lines of translation as on Linux, because the decisions belong to `plan_from_link`: a resolved link becomes the call a tile click makes, with the same wake, trust and error surfaces. Never preempting a live session is checked here, since only this layer knows one is running. The manifest declares the protocol and an app execution alias rather than writing registry keys, so uninstall leaves nothing behind — and Windows' own "allow this app to handle punktfunk links?" prompt is the origin friction the design wants, not duplicated in-app. (Manifest comments are free of double hyphens: XML forbids them and makepri rejects the whole file, which is how the console flag broke the v0.15.0 build. A test parses the substituted manifest and asserts both declarations, so that can't rot silently.) "Create shortcut…" writes a `.lnk` targeting the ALIAS with the URL as an argument, not a `.url` internet shortcut: both work while the scheme is registered, only this one still works if it isn't, and targeting the alias keeps it valid across updates when the package path changes. Verified on the CI VM (192.168.1.133): clippy -D warnings clean and its tests green. Not yet exercised on glass — `start punktfunk://…` cold and with the app running, and a written shortcut, still want a real desktop session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
278 lines
12 KiB
Rust
278 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)
|
|
|
|
// 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.
|
|
unsafe {
|
|
use windows::Win32::System::Console::{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::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);
|
|
}
|