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
+10
View File
@@ -33,3 +33,13 @@ sdl3 = { version = "0.18", features = ["hidapi", "ash"] }
[target.'cfg(windows)'.dependencies]
sdl3 = { version = "0.18", features = ["hidapi", "ash", "build-from-source"] }
# Window branding only (win32.rs): the exe-embedded icon onto the SDL window's title
# bar/taskbar (WM_SETICON) and the shared AppUserModelID for unpackaged runs, so the
# shell and the session window group as ONE taskbar app across the visibility handoff.
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Storage_Packaging_Appx",
"Win32_System_LibraryLoader",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
+2
View File
@@ -27,6 +27,8 @@ pub mod overlay;
mod run;
#[cfg(any(target_os = "linux", windows))]
pub mod vk;
#[cfg(windows)]
mod win32;
#[cfg(any(target_os = "linux", windows))]
pub use run::{run_browse, run_session, ActionOutcome, Outcome, SessionOpts};
+14
View File
@@ -226,6 +226,10 @@ impl StreamState {
}
fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>> {
// Before any window exists: unpackaged runs adopt the shell's AppUserModelID so the
// shell⇄session windows group as one taskbar app (win32.rs; MSIX identity wins).
#[cfg(windows)]
crate::win32::set_app_user_model_id();
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
let sdl = sdl3::init().context("SDL init")?;
let video = sdl.video().context("SDL video")?;
@@ -241,12 +245,22 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
b.build().context("SDL window")?
};
// The exe-embedded icon onto the title bar/taskbar/Alt-Tab (SDL's class icon is the
// generic default); a no-op for exes that embed none.
#[cfg(windows)]
crate::win32::stamp_window_icon(&window);
let instance_exts = window
.vulkan_instance_extensions()
.map_err(|e| anyhow::anyhow!("vulkan instance extensions: {e}"))?;
let mut presenter = Presenter::new(&window, &instance_exts).context("vulkan presenter")?;
// A valid black frame immediately — the window is honest while the connect runs.
presenter.present(&window, FrameInput::Redraw, None)?;
// Browse mode is "ready" the moment the library window presents — there may never be
// a stream. (Single mode announces on the first VIDEO frame instead, further down, so
// a shell only yields to a window that actually shows the stream.)
if opts.json_status && matches!(mode, ModeCtl::Browse(_)) {
println!("{{\"ready\":true}}");
}
let mut overlay = opts.overlay.take();
if let Some(o) = overlay.as_mut() {
+66
View File
@@ -0,0 +1,66 @@
//! Windows-only window branding for the SDL session window.
//!
//! SDL registers its window class with a generic icon, so without help the taskbar and
//! Alt-Tab show the SDL default even when the exe embeds ours. [`stamp_window_icon`]
//! stamps the exe's icon resource (ordinal 1, when embedded — see the session binary's
//! `build.rs`) onto the window via `WM_SETICON`, at the native small/big metrics so
//! nothing is scaled at draw time — the same treatment the WinUI shell gives its window.
//!
//! [`set_app_user_model_id`] gives unpackaged (dev/CLI) runs the same explicit
//! AppUserModelID as the shell, so the two windows group as ONE taskbar app across the
//! shell⇄session visibility handoff. MSIX runs already share the package identity, and
//! overriding it would detach the window from the Start-menu pin — so packaged processes
//! are left alone.
use windows_sys::core::w;
use windows_sys::Win32::Foundation::{APPMODEL_ERROR_NO_PACKAGE, HWND, LPARAM, WPARAM};
use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
use windows_sys::Win32::UI::WindowsAndMessaging::{
GetSystemMetrics, LoadImageW, SendMessageW, ICON_BIG, ICON_SMALL, IMAGE_ICON, LR_DEFAULTCOLOR,
SM_CXICON, SM_CXSMICON, WM_SETICON,
};
/// The shared taskbar identity — must stay in sync with the WinUI shell's
/// (`clients/windows/src/main.rs`), or the two windows stop grouping.
const APP_USER_MODEL_ID: *const u16 = w!("unom.punktfunk.client");
/// Tag this process with the shared AppUserModelID — unpackaged runs only (a packaged
/// process already carries the MSIX identity, which must win). Call before any window
/// exists; later calls don't re-tag existing windows.
pub(crate) fn set_app_user_model_id() {
unsafe {
let mut len: u32 = 0;
// No buffer: just probe whether the process has package identity.
if GetCurrentPackageFullName(&mut len, std::ptr::null_mut()) != APPMODEL_ERROR_NO_PACKAGE {
return; // packaged (or indeterminate) — leave the identity alone
}
let _ = SetCurrentProcessExplicitAppUserModelID(APP_USER_MODEL_ID);
}
}
/// Stamp the exe-embedded icon (resource ordinal 1) onto the SDL window's title bar,
/// taskbar and Alt-Tab. A quiet no-op when the exe embeds no icon.
pub(crate) fn stamp_window_icon(window: &sdl3::video::Window) {
unsafe {
// The HWND behind the SDL window, via SDL3's window properties (the same route
// the sdl3 crate's raw-window-handle integration takes).
let hwnd: HWND = sdl3::sys::properties::SDL_GetPointerProperty(
sdl3::sys::video::SDL_GetWindowProperties(window.raw()),
sdl3::sys::video::SDL_PROP_WINDOW_WIN32_HWND_POINTER,
std::ptr::null_mut(),
) as HWND;
if hwnd.is_null() {
return;
}
let module = GetModuleHandleW(std::ptr::null());
for (which, metric) in [(ICON_SMALL, SM_CXSMICON), (ICON_BIG, SM_CXICON)] {
let px = GetSystemMetrics(metric);
let icon = LoadImageW(module, 1 as *const u16, IMAGE_ICON, px, px, LR_DEFAULTCOLOR);
if !icon.is_null() {
SendMessageW(hwnd, WM_SETICON, which as WPARAM, icon as LPARAM);
}
}
}
}