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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
] }
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user