feat(clients/windows): single-window handoff, shared settings store, console-UI surfacing

UX polish batch 1 (clients/windows/ui-polish-plan.md, workstreams W0/A1/B/C/E):

- W0: the shell's duplicated trust/settings structs are gone — src/trust.rs
  re-exports pf-client-core's, so the shell and the spawned session binary share
  ONE Settings shape over client-windows-settings.json. Fixes real bugs: the
  shell's saves no longer drop session-side fields; the stats-overlay toggle
  (show_hud -> show_stats, serde alias migrates old files) and the forwarded-
  controller pick now actually reach the spawned session. The "Streaming engine"
  Settings combo is gone (PUNKTFUNK_BUILTIN_STREAM=1 stays as the dev A/B knob).

- Forwarded-controller pinning by stable key (vid:pid:name, pf-client-core's
  format): persisted as forward_pad, applied by the shell's own service at
  startup AND by the session binary in session_params (both OSes — the session
  never applied the pin before).

- A1 single window: the shell hides itself when the spawned session window
  presents its first frame ({"ready":true}) and restores + foregrounds on the
  child's exit — exactly one visible Punktfunk window at any time. Restore runs
  before the request-access cancel gate so a Ready/Cancel race can't strand the
  shell hidden.

- C console UI: punktfunk-session --browse gains --json-status (ready when the
  library window presents; error line on a failed start), and the shell
  surfaces it — "Open console UI" on paired hosts' menus, plus a controller-
  detected hint card targeting the most recent paired host (x64 only; aarch64
  ships no skia ui feature). Browse spawns hide/restore the shell like streams.

- B responsive: minimum window size (420x360); the hosts header collapses to
  icon-only buttons below 700 px; the session status card shrinks instead of
  clipping (max_width); busy pages, help actions, licenses and long labels wrap.

- E polish: session exe gets the app icon (winresource + WM_SETICON via the new
  pf-presenter win32.rs); both exes share an explicit AppUserModelID on
  unpackaged runs (packaged identity wins) so the windows group as one taskbar
  app across the visibility handoff; "Start streams fullscreen" toggle passes
  --fullscreen (GTK parity); connects stamp KnownHost::last_used; the connect
  target is stashed for every route so "Connecting to X" always names the host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 00:25:02 +02:00
parent d6647b9183
commit 573b2af334
26 changed files with 630 additions and 375 deletions
+18 -16
View File
@@ -3,10 +3,11 @@
//! 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 (`clients/windows/src/trust.rs`) writes — pairing there makes
//! the session connect silently, mirroring the GTK-shell arrangement on Linux. The two
//! `Settings` structs differ in shape; `#[serde(default)]` on both sides reconciles them
//! (see the parity tests below), and the shell stays the settings file's only writer.
//! 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;
@@ -282,6 +283,8 @@ pub struct Settings {
#[serde(default = "default_true")]
pub hdr_enabled: bool,
/// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S).
/// `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted this as `show_hud`.
#[serde(alias = "show_hud")]
pub show_stats: bool,
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
@@ -338,9 +341,8 @@ impl Default for Settings {
impl Settings {
fn path() -> Result<PathBuf> {
// The shell's settings file on each OS: the GTK shell's on Linux, the WinUI
// shell's on Windows. The shells own (and write) these files; the session binary
// only reads them, so `save` must never be called on Windows — it would rewrite
// the file in THIS struct's shape and drop the WinUI-only fields.
// shell's on Windows. The shells own (and write) these files through this one
// struct; the session binary only reads them and must never call `save`.
#[cfg(windows)]
return Ok(config_dir()?.join("client-windows-settings.json"));
#[cfg(not(windows))]
@@ -379,12 +381,11 @@ mod tests {
assert_eq!(round.forward_pad, "");
}
/// On Windows the session reads the WinUI shell's settings file. This fixture is the
/// shell's `Settings` shape (clients/windows/src/trust.rs) verbatim — if that struct
/// changes, update this fixture with it. WinUI-only fields (hdr_enabled, adapter,
/// show_hud) must be ignored; fields this struct has and the shell's lacks
/// (forward_pad, show_stats, …) must default; the shell's D3D11VA-era
/// `decoder: "hardware"` must survive as-is (video::Decoder::new reads it as auto).
/// 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#"{
@@ -392,7 +393,7 @@ mod tests {
"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
"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));
@@ -403,9 +404,10 @@ mod tests {
assert_eq!(s.preferred_codec(), punktfunk_core::quic::CODEC_AV1);
assert_eq!(s.adapter, "NVIDIA GeForce RTX 4080");
assert!(s.hdr_enabled);
// Fields the shell's file doesn't carry take this struct's defaults.
// 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.show_stats);
assert!(s.fullscreen_on_stream);
assert!(!s.library_enabled);
}