From 573b2af334b726ab8311e9cb0a85f366b80765a4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 9 Jul 2026 00:25:02 +0200 Subject: [PATCH] feat(clients/windows): single-window handoff, shared settings store, console-UI surfacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 3 + clients/session/Cargo.toml | 5 + clients/session/build.rs | 19 ++ clients/session/src/browse.rs | 10 +- clients/session/src/main.rs | 14 +- clients/windows/Cargo.toml | 7 + clients/windows/src/app/connect.rs | 67 +++++++ clients/windows/src/app/help.rs | 1 + clients/windows/src/app/hosts.rs | 100 ++++++++++- clients/windows/src/app/licenses.rs | 4 + clients/windows/src/app/mod.rs | 44 ++++- clients/windows/src/app/pair.rs | 1 + clients/windows/src/app/settings.rs | 65 ++++--- clients/windows/src/app/stream.rs | 56 ++++-- clients/windows/src/app/style.rs | 6 + clients/windows/src/gamepad.rs | 58 +++--- clients/windows/src/input.rs | 8 +- clients/windows/src/main.rs | 25 +++ clients/windows/src/shell_window.rs | 55 ++++++ clients/windows/src/spawn.rs | 65 +++++-- clients/windows/src/trust.rs | 266 ++-------------------------- crates/pf-client-core/src/trust.rs | 34 ++-- crates/pf-presenter/Cargo.toml | 10 ++ crates/pf-presenter/src/lib.rs | 2 + crates/pf-presenter/src/run.rs | 14 ++ crates/pf-presenter/src/win32.rs | 66 +++++++ 26 files changed, 630 insertions(+), 375 deletions(-) create mode 100644 clients/session/build.rs create mode 100644 clients/windows/src/shell_window.rs create mode 100644 crates/pf-presenter/src/win32.rs diff --git a/Cargo.lock b/Cargo.lock index 74f46c20..235d9062 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2827,6 +2827,7 @@ dependencies = [ "punktfunk-core", "sdl3", "tracing", + "windows-sys 0.61.2", ] [[package]] @@ -3041,6 +3042,7 @@ dependencies = [ "serde_json", "tracing", "tracing-subscriber", + "winresource", ] [[package]] @@ -3053,6 +3055,7 @@ dependencies = [ "ffmpeg-next", "mdns-sd", "opus", + "pf-client-core", "punktfunk-core", "sdl3", "serde", diff --git a/clients/session/Cargo.toml b/clients/session/Cargo.toml index d82de1c9..0144d2ae 100644 --- a/clients/session/Cargo.toml +++ b/clients/session/Cargo.toml @@ -31,3 +31,8 @@ serde_json = { version = "1", optional = true } anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Embeds the app icon as an exe resource (build.rs) — Windows hosts only (rc.exe from +# the SDK); same pattern as clients/windows. +[target.'cfg(windows)'.build-dependencies] +winresource = "0.1" diff --git a/clients/session/build.rs b/clients/session/build.rs new file mode 100644 index 00000000..bb475a4b --- /dev/null +++ b/clients/session/build.rs @@ -0,0 +1,19 @@ +//! Embed the Windows version-info + icon resources into `punktfunk-session.exe`. The +//! icon drives Explorer, and `pf-presenter`'s `win32::stamp_window_icon` loads it by +//! ordinal 1 onto the SDL window's title bar / taskbar / Alt-Tab (SDL's own window-class +//! icon is the generic default). + +fn main() { + // cfg(windows) is the HOST (skips Linux/macOS builds of this cross-platform binary); + // CARGO_CFG_WINDOWS is the TARGET (x64 and cross-compiled ARM64 both pass). + #[cfg(windows)] + if std::env::var_os("CARGO_CFG_WINDOWS").is_some() { + let icon = "../../packaging/windows/branding/punktfunk.ico"; + println!("cargo:rerun-if-changed={icon}"); + winresource::WindowsResource::new() + // Ordinal 1 — pf-presenter's win32.rs loads it by this id for WM_SETICON. + .set_icon_with_id(icon, "1") + .compile() + .expect("embed windows icon resource"); + } +} diff --git a/clients/session/src/browse.rs b/clients/session/src/browse.rs index 5b926eda..0f7b18c1 100644 --- a/clients/session/src/browse.rs +++ b/clients/session/src/browse.rs @@ -55,11 +55,14 @@ pub fn run(target: &str) -> u8 { shared.set_phase(LibraryPhase::PairFirst); } + // `--json-status`: a shell parent is reading stdout (the WinUI shell hides itself on + // `{"ready":true}` and restores on exit) — plain CLI/gamescope runs stay silent. + let json_status = arg_flag("--json-status"); let opts = pf_presenter::SessionOpts { window_title: format!("Punktfunk · {host_label}"), fullscreen: fullscreen_mode(), print_stats: settings.show_stats || arg_flag("--stats"), - json_status: false, // browse has no shell parent reading stdout + json_status, on_connected: Some(Box::new(|fingerprint: [u8; 32]| { trust::touch_last_used(&trust::hex(&fingerprint)); })), @@ -101,6 +104,11 @@ pub fn run(target: &str) -> u8 { match result { Ok(()) => 0, Err(e) => { + // The shell contract's terminal line (a clean quit needs none — stdout EOF + // already routes the shell back to its host list silently). + if json_status { + crate::session_main::json_line("error", &format!("{e:#}"), Some(false)); + } eprintln!("browse: {e:#}"); crate::session_main::EXIT_PRESENTER_FAILED } diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index 912daab6..c45be83f 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -94,6 +94,13 @@ mod session_main { force_software: Arc, vulkan: Option, ) -> SessionParams { + // Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name` + // key) to OUR gamepad service — the shells' in-process services can't reach this + // process. Applied per params-build (idempotent; browse re-launches included) so + // it lands before the session attaches. Empty = automatic (most recent). + if !settings.forward_pad.is_empty() { + gamepad.set_pinned(Some(settings.forward_pad.clone())); + } let mode = Mode { width: if settings.width == 0 { native.width @@ -145,8 +152,9 @@ mod session_main { } /// One JSON status line on stdout (the shell parses these; strings hand-escaped via - /// the minimal rules a reason string can need). - fn json_line(key: &str, msg: &str, trust_rejected: Option) { + /// the minimal rules a reason string can need). `pub(crate)`: browse mode emits its + /// failure through the same contract when spawned with `--json-status`. + pub(crate) fn json_line(key: &str, msg: &str, trust_rejected: Option) { let escaped: String = msg .chars() .flat_map(|c| match c { @@ -243,7 +251,7 @@ mod session_main { let Some(target) = arg_value("--connect") else { eprintln!( "usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen]\n\ - \x20 punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen]\n\ + \x20 punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen] [--json-status]\n\ \n\ Streams from a paired punktfunk host in a Vulkan window; --browse opens the\n\ console game library instead (paired hosts only). Pair first via the\n\ diff --git a/clients/windows/Cargo.toml b/clients/windows/Cargo.toml index 3894ffa9..54c08864 100644 --- a/clients/windows/Cargo.toml +++ b/clients/windows/Cargo.toml @@ -20,6 +20,10 @@ path = "src/main.rs" # The protocol core, linked directly (no C ABI) — same as the GTK Linux client. NativeClient # is Sync (mutexed plane receivers), so it drops into a UI app cleanly. punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] } +# The shared client service layer: the trust/settings stores (ONE `Settings` struct for the +# shell and the spawned session binary — src/trust.rs re-exports it) and the game-library +# data model (fetch + art pipeline) behind the library page. +pf-client-core = { path = "../../crates/pf-client-core" } # WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its # `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri @@ -33,6 +37,9 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63 "Win32_Foundation", "Win32_Graphics_Dxgi", "Win32_Graphics_Dxgi_Common", + # GetCurrentPackageFullName — the packaged-run probe guarding the explicit + # AppUserModelID (main.rs; MSIX identity must win over the dev-run tag). + "Win32_Storage_Packaging_Appx", "Win32_Graphics_Direct3D", "Win32_Graphics_Direct3D11", "Win32_Graphics_Direct3D_Fxc", diff --git a/clients/windows/src/app/connect.rs b/clients/windows/src/app/connect.rs index 2c592c34..e61f7b34 100644 --- a/clients/windows/src/app/connect.rs +++ b/clients/windows/src/app/connect.rs @@ -50,6 +50,9 @@ fn initiate_opts( set_status: &AsyncSetState, wake_on_fail: bool, ) { + // Every route reads the target back for its screen copy ("Connecting to X", + // "Streaming to X") — stash it up front, not just on the pairing route. + *ctx.shared.target.lock().unwrap() = target.clone(); let known = KnownHosts::load(); let pin = target .fp_hex @@ -292,10 +295,12 @@ fn connect_with( port: target.port, fp_hex: trust::hex(&fingerprint), paired: persist_paired, + last_used: None, mac: target.mac.clone(), }); let _ = k.save(); } + trust::touch_last_used(&trust::hex(&fingerprint)); gamepad.attach(connector.clone()); *shared.stats.lock().unwrap() = Stats::default(); // clear any prior session's numbers *shared.handoff.lock().unwrap() = @@ -367,6 +372,8 @@ fn connect_spawn( let child = crate::spawn::SessionChild::default(); *ctx.shared.session.lock().unwrap() = child.clone(); ctx.shared.stats_line.lock().unwrap().clear(); + ctx.shared.browse.store(false, Ordering::SeqCst); + let fullscreen = ctx.settings.lock().unwrap().fullscreen_on_stream; set_status.call(String::new()); set_screen.call(if opts.awaiting_approval { Screen::RequestAccess @@ -388,9 +395,16 @@ fn connect_spawn( port, &fp_arg, opts.connect_timeout.as_secs(), + fullscreen, + None, child, move |event| { use crate::spawn::SpawnEvent; + // The child is gone — bring the shell back BEFORE the cancel gate below, so a + // Ready that raced a Cancel (and hid the shell) can never strand it hidden. + if matches!(event, SpawnEvent::Exited { .. }) { + crate::shell_window::restore(); + } // A cancelled request-access connect that resolved late: tear down silently — // Cancel already killed the child and returned the UI to the host list. if cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) { @@ -410,10 +424,15 @@ fn connect_spawn( port: target.port, fp_hex: fp_hex.clone(), paired: persist_paired, + last_used: None, mac: target.mac.clone(), }); let _ = k.save(); } + // The child presented its first frame — its window is up, so the + // shell yields: one visible Punktfunk window at a time. Every exit + // path restores it (the `Exited` handling above). + crate::shell_window::hide(); ss.call(Screen::Stream); } SpawnEvent::Stats(line) => *shared.stats_line.lock().unwrap() = line, @@ -452,6 +471,53 @@ fn connect_spawn( } } +/// "Open console UI": run the gamepad library (`punktfunk-session --browse`) for a +/// PAIRED host in the session window. The shell yields exactly like a stream — hidden on +/// the library window's `ready`, restored when the child exits (launched titles stream +/// in that same window, so the whole couch round-trip happens without the shell). +pub(crate) fn open_console( + ctx: &Arc, + target: Target, + set_screen: &AsyncSetState, + set_status: &AsyncSetState, +) { + let child = crate::spawn::SessionChild::default(); + *ctx.shared.session.lock().unwrap() = child.clone(); + ctx.shared.stats_line.lock().unwrap().clear(); + ctx.shared.browse.store(true, Ordering::SeqCst); + *ctx.shared.target.lock().unwrap() = target.clone(); + let fullscreen = ctx.settings.lock().unwrap().fullscreen_on_stream; + set_status.call(String::new()); + set_screen.call(Screen::Connecting); + + let shared = ctx.shared.clone(); + let (ss, st) = (set_screen.clone(), set_status.clone()); + let spawned = + crate::spawn::spawn_browse(&target.addr, target.port, fullscreen, child, move |event| { + use crate::spawn::SpawnEvent; + match event { + SpawnEvent::Ready => { + // The library window presented — the shell yields (same one-visible- + // window rule as a stream). + crate::shell_window::hide(); + ss.call(Screen::Stream); + } + SpawnEvent::Stats(line) => *shared.stats_line.lock().unwrap() = line, + SpawnEvent::Exited { error, ended } => { + crate::shell_window::restore(); + // Quit from the library (B / closing the window) returns silently; + // a failed start surfaces its error line. + st.call(error.map(|(msg, _)| msg).or(ended).unwrap_or_default()); + ss.call(Screen::Hosts); + } + } + }); + if let Err(e) = spawned { + set_status.call(e); + set_screen.call(Screen::Hosts); + } +} + /// The no-PIN "request access" flow: open an identified connect that the host PARKS until the /// operator approves this device in its console (or web UI), showing a cancelable "waiting" /// screen meanwhile. On approval the SAME connection is admitted (no reconnect) and the host is @@ -553,6 +619,7 @@ fn wake_and_connect( port: target.port, fp_hex: fp, paired: false, + last_used: None, mac: target.mac.clone(), }); let _ = k.save(); diff --git a/clients/windows/src/app/help.rs b/clients/windows/src/app/help.rs index 7c64f6b5..3738aa99 100644 --- a/clients/windows/src/app/help.rs +++ b/clients/windows/src/app/help.rs @@ -39,6 +39,7 @@ fn shortcuts_reference() -> Element { let row = i as i32; children.push(key_chip(keys).grid_row(row).grid_column(0)); let action_cell: Element = text_block(*action) + .wrap() .foreground(ThemeRef::SecondaryText) .vertical_alignment(VerticalAlignment::Center) .into(); diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index 72d02c1d..bfabf2c7 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -2,7 +2,7 @@ //! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename / //! forget) and a manual connect entry — the same card layout as the Linux and Apple clients. -use super::connect::{initiate, initiate_waking}; +use super::connect::{initiate, initiate_waking, open_console}; use super::speed::SpeedState; use super::style::*; use super::{Screen, Svc, Target}; @@ -12,11 +12,17 @@ use windows_reactor::*; /// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text. const MENU_CONNECT: &str = "Connect"; +const MENU_CONSOLE: &str = "Open console UI"; const MENU_SPEED: &str = "Test network speed\u{2026}"; const MENU_WAKE: &str = "Wake host"; const MENU_RENAME: &str = "Rename\u{2026}"; const MENU_FORGET: &str = "Forget\u{2026}"; +/// Whether the console (gamepad) UI is available in this build: the session binary ships +/// its Skia `ui` feature on x64 only (no skia prebuilts for aarch64 yet) — the entry +/// points compile everywhere but only show where `--browse` can actually run. +const CONSOLE_UI_AVAILABLE: bool = cfg!(target_arch = "x86_64"); + /// Tile-grid metrics: minimum tile width before dropping a column, and the gap between tiles. const TILE_MIN_WIDTH: f64 = 320.0; const TILE_GAP: f64 = 12.0; @@ -37,6 +43,9 @@ pub(crate) struct HostsProps { pub(crate) svc: Svc, pub(crate) hosts: Vec, pub(crate) status: String, + /// Connected-controller count (root state, mirrored from the gamepad service) — a + /// pad plus a paired host surfaces the "Open console UI" hint card. + pub(crate) pads: usize, pub(crate) forget: Option<(String, String)>, pub(crate) rename: Option<(String, String)>, /// Whether the "Add host" modal is open. Root state (like `forget`/`rename`), not the page's @@ -60,6 +69,7 @@ impl PartialEq for HostsProps { self.svc == other.svc && self.hosts == other.hosts && self.status == other.status + && self.pads == other.pads && self.forget == other.forget && self.rename == other.rename && self.show_add == other.show_add @@ -93,6 +103,7 @@ fn host_tile( text_block(name) .font_size(15.0) .semibold() + .wrap() .margin(edges(0.0, 12.0, 0.0, 0.0)), text_block(sub) .font_size(12.0) @@ -258,30 +269,41 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { let window = cx.use_inner_size(); let content_w = (window.width - 64.0).clamp(TILE_MIN_WIDTH, 1120.0); let cols = (((content_w + TILE_GAP) / (TILE_MIN_WIDTH + TILE_GAP)).floor() as usize).max(1); + // Compact header: below this the three labelled buttons would collide with the title + // (the Auto grid column can't shrink), so they drop to icon-only with tooltips. + let compact = window.width < 700.0; + let header_btn = |label: &str, sym: Symbol| { + if compact { + button("").icon(sym).tooltip(label).automation_name(label) + } else { + button(label).icon(sym) + } + }; let mut body: Vec = Vec::new(); - // Header: title block + Settings button. + // Header: title block + Add host / Help / Settings. body.push( grid(( vstack(( text_block("Punktfunk").font_size(30.0).bold(), text_block("Stream from a host on your network.") + .wrap() .foreground(ThemeRef::SecondaryText), )) .spacing(2.0) .grid_column(0) .vertical_alignment(VerticalAlignment::Center), hstack(( - button("Add host").accent().icon(Symbol::Add).on_click({ + header_btn("Add host", Symbol::Add).accent().on_click({ let sa = set_show_add.clone(); move || sa.call(true) }), - button("Help").icon(Symbol::Help).on_click({ + header_btn("Help", Symbol::Help).on_click({ let ss = set_screen.clone(); move || ss.call(Screen::Help) }), - button("Settings").icon(Symbol::Setting).on_click({ + header_btn("Settings", Symbol::Setting).on_click({ let ss = set_screen.clone(); move || ss.call(Screen::Settings) }), @@ -305,6 +327,63 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { ); } + // A controller is connected and a paired host exists: offer the couch experience — + // the console (gamepad) UI on the most recently used paired host. + if CONSOLE_UI_AVAILABLE && props.pads > 0 { + if let Some(k) = known + .hosts + .iter() + .filter(|h| h.paired) + .max_by_key(|h| h.last_used.unwrap_or(0)) + { + let target = Target { + name: k.name.clone(), + addr: k.addr.clone(), + port: k.port, + fp_hex: Some(k.fp_hex.clone()), + pair_optional: false, + mac: k.mac.clone(), + }; + let svc = props.svc.clone(); + body.push( + card( + grid(( + vstack(( + text_block("Controller detected").font_size(14.0).semibold(), + text_block(format!( + "Browse {}\u{2019}s game library with the gamepad \u{2014} \ + launches stream in the same window.", + k.name + )) + .font_size(12.0) + .wrap() + .foreground(ThemeRef::SecondaryText), + )) + .spacing(2.0) + .grid_column(0) + .vertical_alignment(VerticalAlignment::Center), + button("Open console UI") + .accent() + .icon(Symbol::Play) + .on_click(move || { + open_console( + &svc.ctx, + target.clone(), + &svc.set_screen, + &svc.set_status, + ) + }) + .grid_column(1) + .vertical_alignment(VerticalAlignment::Center) + .margin(edges(12.0, 0.0, 0.0, 0.0)), + )) + .columns([GridLength::Star(1.0), GridLength::Auto]), + ) + .into(), + ); + } + } + // Saved (trusted/paired) hosts — reachable even when mDNS isn't. A saved host that's also // being advertised right now shows as Online (and is deduped out of the discovery section). if !known.hosts.is_empty() { @@ -347,7 +426,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { .tooltip("More options") .automation_name("More options") .menu_flyout({ - let mut items = vec![menu_item(MENU_CONNECT), menu_item(MENU_SPEED)]; + let mut items = vec![menu_item(MENU_CONNECT)]; + // The gamepad library — paired hosts only (the mgmt API needs the + // paired identity) and only where the session ships the console UI. + if CONSOLE_UI_AVAILABLE && k.paired { + items.push(menu_item(MENU_CONSOLE)); + } + items.push(menu_item(MENU_SPEED)); // Offer an explicit wake only when the host is offline and we have a MAC. if can_wake { items.push(menu_item(MENU_WAKE)); @@ -361,6 +446,9 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { MENU_CONNECT => { initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status) } + MENU_CONSOLE => { + open_console(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status) + } MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()), MENU_SPEED => { *svc.ctx.shared.target.lock().unwrap() = target.clone(); diff --git a/clients/windows/src/app/licenses.rs b/clients/windows/src/app/licenses.rs index c13ff72d..b58faafb 100644 --- a/clients/windows/src/app/licenses.rs +++ b/clients/windows/src/app/licenses.rs @@ -26,9 +26,11 @@ pub(crate) fn licenses_page(set_screen: &AsyncSetState) -> Element { text_block("punktfunk").font_size(15.0).semibold(), text_block("Licensed under MIT OR Apache-2.0, at your option.") .font_size(12.0) + .wrap() .foreground(ThemeRef::SecondaryText), text_block(APP_LICENSE) .font_size(11.0) + .wrap() .foreground(ThemeRef::SecondaryText), )) .spacing(8.0), @@ -43,6 +45,7 @@ pub(crate) fn licenses_page(set_screen: &AsyncSetState) -> Element { Windows App SDK (Microsoft) are also linked.", ) .font_size(12.0) + .wrap() .foreground(ThemeRef::SecondaryText), )) .spacing(8.0), @@ -53,6 +56,7 @@ pub(crate) fn licenses_page(set_screen: &AsyncSetState) -> Element { text_block("Rust crates").font_size(15.0).semibold(), text_block(THIRD_PARTY_NOTICES) .font_size(11.0) + .wrap() .foreground(ThemeRef::SecondaryText), )) .spacing(8.0), diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index 75895b8c..78e3cc19 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -133,6 +133,9 @@ pub(crate) struct Shared { /// only publishes its outcome while its generation is still current, so a test abandoned /// mid-run can't overwrite a newer run's result when it finally resolves. pub(crate) speed_gen: std::sync::atomic::AtomicU64, + /// Whether the live session child is a `--browse` console-UI run (vs a stream) — the + /// session status page words itself accordingly. Set by each spawn. + pub(crate) browse: std::sync::atomic::AtomicBool, } pub struct AppCtx { @@ -143,12 +146,11 @@ pub struct AppCtx { } /// The legacy in-process streaming path (SwapChainPanel + D3D11VA) instead of the -/// spawned punktfunk-session window: the Settings "Streaming engine" pick, or the -/// `PUNKTFUNK_BUILTIN_STREAM=1` env override. A temporary A/B knob — both go away with -/// the legacy path once the Vulkan session is fully validated. -pub(crate) fn use_builtin_stream(ctx: &AppCtx) -> bool { +/// spawned punktfunk-session window: the `PUNKTFUNK_BUILTIN_STREAM=1` env override — a +/// developer A/B knob only (the former Settings "Streaming engine" pick is gone), removed +/// with the legacy path once the Vulkan session is fully validated. +pub(crate) fn use_builtin_stream(_ctx: &AppCtx) -> bool { std::env::var_os("PUNKTFUNK_BUILTIN_STREAM").is_some_and(|v| v == "1") - || ctx.settings.lock().unwrap().engine == "builtin" } pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> { @@ -158,10 +160,25 @@ pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_react gamepad, shared: Arc::new(Shared::default()), }); + // Re-apply the persisted forwarded-controller pin (stable key; the service matches it + // whenever such a pad connects) — GTK-shell parity. + { + let forward = ctx.settings.lock().unwrap().forward_pad.clone(); + if !forward.is_empty() { + ctx.gamepad.set_pinned(Some(forward)); + } + } apply_window_icon_when_ready(); App::new() .title("Punktfunk") .inner_size(1000.0, 720.0) + // A floor under every layout: below this the header/cards would clip rather than + // reflow (the pages adapt down to it — see the hosts page's responsive grid). + .inner_constraints(InnerConstraints { + min_width: Some(420.0), + min_height: Some(360.0), + ..Default::default() + }) .backdrop(Backdrop::Mica) .render(move |cx| root(cx, &ctx)) } @@ -232,6 +249,22 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { let (hover, set_hover) = cx.use_async_state(Option::::None); // Which Settings section the NavigationView shows (persists across visits this run). let (settings_nav, set_settings_nav) = cx.use_async_state("display".to_string()); + // Connected-controller count, mirrored from the gamepad service by a poll thread + // (thread-driven state must be root state — see the module docs). Drives the hosts + // page's "Open console UI" hint; the compare in `call` makes the steady state free. + let (pads, set_pads) = cx.use_async_state(0usize); + cx.use_effect((), { + let (gp, set_pads) = (ctx.gamepad.clone(), set_pads.clone()); + move || { + std::thread::Builder::new() + .name("pf-pads".into()) + .spawn(move || loop { + std::thread::sleep(std::time::Duration::from_secs(2)); + set_pads.call(gp.pads().len()); + }) + .ok(); + } + }); // Continuous LAN discovery (spawned once). cx.use_effect((), { @@ -392,6 +425,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { svc, hosts, status, + pads, forget, rename, show_add, diff --git a/clients/windows/src/app/pair.rs b/clients/windows/src/app/pair.rs index 8213dc10..5fe3e0bb 100644 --- a/clients/windows/src/app/pair.rs +++ b/clients/windows/src/app/pair.rs @@ -50,6 +50,7 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element { port: target3.port, fp_hex: trust::hex(&fp), paired: true, + last_used: None, mac: target3.mac.clone(), }); let _ = k.save(); diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 698d190b..48989f7c 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -26,11 +26,6 @@ const DECODERS: &[(&str, &str)] = &[ ("vulkan", "Hardware (GPU / Vulkan Video)"), ("software", "Software (CPU)"), ]; -/// Temporary A/B knob (see `Settings::engine`) — deleted with the legacy path. -const ENGINES: &[(&str, &str)] = &[ - ("", "Vulkan session window (recommended)"), - ("builtin", "Built-in D3D11VA (legacy)"), -]; /// Audio channel presets: `(channel count, display label)`. The host clamps to what it can /// capture; the resolved count drives the decoder + WASAPI render layout. const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")]; @@ -182,6 +177,13 @@ pub(crate) fn settings_page( "Linux hosts only, and advisory \u{2014} the host falls back to auto-detect when the \ choice is unavailable.", ); + let fullscreen_toggle = setting_toggle( + ctx, + "Start streams fullscreen", + s.fullscreen_on_stream, + |s, on| s.fullscreen_on_stream = on, + ) + .tooltip("The stream window opens fullscreen; F11 or Alt+Enter switches back live."); // --- Video ----------------------------------------------------------------------------- let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder); @@ -246,19 +248,11 @@ pub(crate) fn settings_page( "Advertise 10-bit HDR10 so the host upgrades HDR content. Needs a display in HDR mode; \ SDR content is unaffected.", ); - let (eng_names, eng_i) = presets(ENGINES, |v| *v == s.engine); - let engine_combo = setting_combo(ctx, "Streaming engine", eng_names, eng_i, |s, i| { - s.engine = ENGINES[i].0.to_string(); - }) - .tooltip( - "Temporary: compare the Vulkan session window against the legacy in-process \ - D3D11VA presenter. Applies to the next stream. This option goes away once the \ - Vulkan path is fully validated.", - ); // --- Input ----------------------------------------------------------------------------- - // Which physical controller forwards as pad 0: automatic = the most recently connected; - // pinning survives until the app exits (Swift/GTK parity). + // Which physical controller forwards as pad 0: automatic = the most recently connected. + // Persisted by stable key (`Settings::forward_pad`, GTK parity) so the pin survives + // restarts AND reaches the spawned session binary, whose service applies the same key. let pads = ctx.gamepad.pads(); let (fwd_names, fwd_i) = { let mut names = vec!["Automatic (most recent)".to_string()]; @@ -270,26 +264,32 @@ pub(crate) fn settings_page( format!("{} \u{00B7} {kind}", p.name) } })); - let i = ctx - .gamepad - .pinned() - .and_then(|id| pads.iter().position(|p| p.id == id)) + let i = (!s.forward_pad.is_empty()) + .then(|| pads.iter().position(|p| p.key == s.forward_pad)) + .flatten() .map_or(0, |i| i + 1); (names, i) }; let forward_combo = { let svc = ctx.gamepad.clone(); - let ids: Vec = pads.iter().map(|p| p.id).collect(); + let ctx2 = ctx.clone(); + let keys: Vec = pads.iter().map(|p| p.key.clone()).collect(); ComboBox::new(fwd_names) .header("Forwarded controller") .selected_index(fwd_i as i32) .on_selection_changed(move |i: i32| { let sel = i.max(0) as usize; - svc.set_pinned(if sel == 0 { + let key = if sel == 0 { None } else { - ids.get(sel - 1).copied() - }); + keys.get(sel - 1).cloned() + }; + // Apply live (the in-process service, legacy builtin streams) and persist — + // the spawned session reads `forward_pad` at connect. + svc.set_pinned(key.clone()); + let mut s = ctx2.settings.lock().unwrap(); + s.forward_pad = key.unwrap_or_default(); + s.save(); }) .tooltip( "Exactly one controller is forwarded to the host; \u{201C}Automatic\u{201D} \ @@ -328,9 +328,12 @@ pub(crate) fn settings_page( ) .tooltip("Sends the default microphone to the host's virtual mic source."); - let hud_toggle = setting_toggle(ctx, "Show the stats overlay (HUD)", s.show_hud, |s, on| { - s.show_hud = on - }) + let hud_toggle = setting_toggle( + ctx, + "Show the stats overlay (HUD)", + s.show_stats, + |s, on| s.show_stats = on, + ) .tooltip( "The in-stream overlay: mode, codec, fps, bitrate, latency, decode path. \ Ctrl+Alt+Shift+S toggles it live while streaming.", @@ -356,7 +359,6 @@ pub(crate) fn settings_page( bitrate_box.into(), hdr_toggle.into(), hud_toggle.into(), - engine_combo.into(), ]); controls }), @@ -376,7 +378,12 @@ pub(crate) fn settings_page( "about" => ("About", settings_card(vec![licenses_button.into()])), _ => ( "Display", - settings_card(vec![res_combo.into(), hz_combo.into(), comp_combo.into()]), + settings_card(vec![ + res_combo.into(), + hz_combo.into(), + fullscreen_toggle.into(), + comp_combo.into(), + ]), ), }; diff --git a/clients/windows/src/app/stream.rs b/clients/windows/src/app/stream.rs index a3ea3583..810feb14 100644 --- a/clients/windows/src/app/stream.rs +++ b/clients/windows/src/app/stream.rs @@ -80,9 +80,9 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element { let connector_ref = cx.use_ref::>>(None); cx.use_effect_with_cleanup((), { let shared = ctx.shared.clone(); - let (inhibit, show_hud) = { + let (inhibit, show_stats) = { let s = ctx.settings.lock().unwrap(); - (s.inhibit_shortcuts, s.show_hud) + (s.inhibit_shortcuts, s.show_stats) }; let connector_ref = connector_ref.clone(); move || { @@ -91,7 +91,7 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element { let clock_offset = connector.clock_offset_ns; connector_ref.set(Some(connector.clone())); PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset))); - crate::input::install(connector, mode, inhibit, show_hud, stop); + crate::input::install(connector, mode, inhibit, show_stats, stop); } Some(|| { RENDER.with(|c| { @@ -166,10 +166,12 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element { pub(crate) fn session_page(ctx: &Arc, hud: &HudSample) -> Element { use super::style::{avatar, card, pill, Pill}; let host = ctx.shared.target.lock().unwrap().name.clone(); - let title = if host.is_empty() { - "Streaming".to_string() - } else { - format!("Streaming to {host}") + let browse = ctx.shared.browse.load(std::sync::atomic::Ordering::SeqCst); + let title = match (browse, host.is_empty()) { + (true, true) => "Console library".to_string(), + (true, false) => format!("Console library \u{00B7} {host}"), + (false, true) => "Streaming".to_string(), + (false, false) => format!("Streaming to {host}"), }; // Header: monogram + title + the one thing worth knowing (where the video went). @@ -179,9 +181,11 @@ pub(crate) fn session_page(ctx: &Arc, hud: &HudSample) -> Element .vertical_alignment(VerticalAlignment::Center), vstack(( text_block(&title).font_size(18.0).semibold(), - text_block("The stream runs in its own window \u{2014} click it to capture input.") - .font_size(12.0) - .foreground(ThemeRef::SecondaryText), + text_block( + "The stream has its own window \u{2014} this one returns when the session ends.", + ) + .font_size(12.0) + .foreground(ThemeRef::SecondaryText), )) .spacing(2.0) .grid_column(1) @@ -196,12 +200,22 @@ pub(crate) fn session_page(ctx: &Arc, hud: &HudSample) -> Element // a chip row (the decode path gets the status colour), the rest dim stage lines. let mut body: Vec = vec![header]; if hud.stats_line.is_empty() { - body.push( - text_block("Waiting for the first stats window\u{2026}") - .font_size(11.0) - .foreground(ThemeRef::SecondaryText) - .into(), - ); + // The child prints `stats:` lines only while its stats view is on (the Settings + // toggle / Ctrl+Alt+Shift+S) — say so instead of waiting forever. Browse idles in + // the library between launches, so no stats there is simply normal: no line. + if !browse { + let msg = if ctx.settings.lock().unwrap().show_stats { + "Waiting for the first stats window\u{2026}" + } else { + "Stats are off \u{2014} Ctrl+Alt+Shift+S in the stream window turns them on." + }; + body.push( + text_block(msg) + .font_size(11.0) + .foreground(ThemeRef::SecondaryText) + .into(), + ); + } } else { let mut segments = hud.stats_line.split(" | "); if let Some(first) = segments.next() { @@ -236,6 +250,7 @@ pub(crate) fn session_page(ctx: &Arc, hud: &HudSample) -> Element Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen", ) .font_size(11.0) + .wrap() .foreground(ThemeRef::SecondaryText) .margin(edges(0.0, 4.0, 0.0, 0.0)) .into(), @@ -253,9 +268,12 @@ pub(crate) fn session_page(ctx: &Arc, hud: &HudSample) -> Element .into() }); - // One centred card, sized like the app's dialogs. - border(card(vstack(body).spacing(12.0).width(520.0))) - .horizontal_alignment(HorizontalAlignment::Center) + // One centred card, sized like the app's dialogs — Stretch + max_width (the `page` + // pattern) instead of a fixed width, so a narrow window shrinks the card instead of + // clipping it while a wide one still centres it at 520. + border(card(vstack(body).spacing(12.0))) + .max_width(520.0) + .margin(uniform(24.0)) .vertical_alignment(VerticalAlignment::Center) .into() } diff --git a/clients/windows/src/app/style.rs b/clients/windows/src/app/style.rs index b2c26cfe..764acfc8 100644 --- a/clients/windows/src/app/style.rs +++ b/clients/windows/src/app/style.rs @@ -117,16 +117,22 @@ pub(crate) fn busy_page(headline: &str, detail: &str, extra: Vec) -> El text_block(headline) .font_size(18.0) .semibold() + .wrap() .horizontal_alignment(HorizontalAlignment::Center) .into(), text_block(detail) + .wrap() .foreground(ThemeRef::SecondaryText) .horizontal_alignment(HorizontalAlignment::Center) .into(), ]; children.extend(extra); + // max_width + side margins so the text column reads well wide AND wraps instead of + // clipping narrow. vstack(children) .spacing(16.0) + .max_width(520.0) + .margin(edges(24.0, 0.0, 24.0, 0.0)) .horizontal_alignment(HorizontalAlignment::Center) .vertical_alignment(VerticalAlignment::Center) .into() diff --git a/clients/windows/src/gamepad.rs b/clients/windows/src/gamepad.rs index bb57f02c..816282a2 100644 --- a/clients/windows/src/gamepad.rs +++ b/clients/windows/src/gamepad.rs @@ -31,8 +31,10 @@ const G: f32 = 9.80665; #[derive(Clone, Debug)] pub struct PadInfo { - /// SDL joystick instance id — the settings GUI's pin key. - pub id: u32, + /// Stable identity (`vid:pid:name`, the same format as `pf-client-core`'s `PadInfo::key`) + /// — persisted as `Settings::forward_pad` so the pin survives restarts AND reaches the + /// spawned session binary, whose own gamepad service applies the same key. + pub key: String, pub name: String, /// The virtual pad "Automatic" resolves to for this physical controller (DualSense → DualSense, /// DS4 → DualShock 4, Xbox One/Series → Xbox One, else → Xbox 360). @@ -74,14 +76,13 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref { enum Ctl { Attach(Arc), Detach, - Pin(Option), + Pin(Option), } #[derive(Clone)] pub struct GamepadService { pads: Arc>>, active: Arc>>, - pinned: Arc>>, // `Arc>` (not a bare `Sender`, which is `!Sync`) so the service is `Sync` — the // WinUI app shares it across the UI thread and the session-pump thread (attach/detach). ctl: Arc>>, @@ -91,13 +92,12 @@ impl GamepadService { pub fn start() -> GamepadService { let pads = Arc::new(Mutex::new(Vec::new())); let active = Arc::new(Mutex::new(None)); - let pinned = Arc::new(Mutex::new(None)); let (ctl, ctl_rx) = std::sync::mpsc::channel(); - let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone()); + let (p, a) = (pads.clone(), active.clone()); if let Err(e) = std::thread::Builder::new() .name("punktfunk-gamepad".into()) .spawn(move || { - if let Err(e) = run(&p, &a, &pin, &ctl_rx) { + if let Err(e) = run(&p, &a, &ctl_rx) { tracing::warn!(error = %e, "gamepad service ended — pads disabled"); } }) @@ -107,7 +107,6 @@ impl GamepadService { GamepadService { pads, active, - pinned, ctl: Arc::new(Mutex::new(ctl)), } } @@ -121,13 +120,11 @@ impl GamepadService { self.active.lock().unwrap().clone() } - /// The user-pinned controller (settings GUI), if any — else auto (most recent). - pub fn pinned(&self) -> Option { - *self.pinned.lock().unwrap() - } - - pub fn set_pinned(&self, id: Option) { - let _ = self.ctl.lock().unwrap().send(Ctl::Pin(id)); + /// Pin the forwarded controller by stable key (`PadInfo::key`) — `None` = automatic. + /// The pin survives the pad disconnecting: it re-applies the moment a matching + /// controller shows up again (same semantics as `pf-client-core`'s service). + pub fn set_pinned(&self, key: Option) { + let _ = self.ctl.lock().unwrap().send(Ctl::Pin(key)); } pub fn attach(&self, connector: Arc) { @@ -251,7 +248,9 @@ struct Worker { opened: HashMap, /// Connection order; the most recently connected is the auto selection. order: Vec, - pinned: Option, + /// The user pin by stable key (`PadInfo::key`); resolved to an instance id per lookup + /// so it re-applies whenever a matching pad (re)connects. + pinned: Option, attached: Option>, /// Wire state of the active pad — zeroed on the wire at switch/detach. last_axis: [i32; 6], @@ -265,7 +264,14 @@ struct Worker { impl Worker { fn active_id(&self) -> Option { self.pinned - .filter(|id| self.opened.contains_key(id)) + .as_deref() + .and_then(|key| { + self.order + .iter() + .rev() // prefer the most recently connected pad with this identity + .find(|&&id| self.pad_info(id).is_some_and(|p| p.key == key)) + .copied() + }) .or_else(|| self.order.last().copied()) } @@ -275,16 +281,18 @@ impl Worker { self.subsystem .type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)), ); + let (vid, pid) = (pad.vendor_id().unwrap_or(0), pad.product_id().unwrap_or(0)); // No SDL type for the Steam Deck / Steam Controller — detect Valve by VID/PID (Deck 0x1205, // SC wired 0x1102, SC dongle 0x1142) so the host builds the virtual hid-steam pad. - if pad.vendor_id() == Some(0x28DE) - && matches!(pad.product_id(), Some(0x1205 | 0x1102 | 0x1142)) - { + if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) { pref = GamepadPref::SteamDeck; } + let name = pad.name().unwrap_or_else(|| "Controller".into()); Some(PadInfo { - id, - name: pad.name().unwrap_or_else(|| "Controller".into()), + // Must match pf-client-core's `PadInfo::key` byte-for-byte — the persisted + // `forward_pad` is applied by BOTH services (this one and the session's). + key: format!("{vid:04x}:{pid:04x}:{name}"), + name, pref, }) } @@ -399,7 +407,6 @@ impl Worker { fn run( pads_out: &Mutex>, active_out: &Mutex>, - pinned_out: &Mutex>, ctl: &Receiver, ) -> Result<(), String> { // Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its own @@ -431,7 +438,6 @@ fn run( list.reverse(); // most recent first — the Settings list order *pads_out.lock().unwrap() = list; *active_out.lock().unwrap() = w.active_id().and_then(|id| w.pad_info(id)); - *pinned_out.lock().unwrap() = w.pinned; }; loop { @@ -448,9 +454,9 @@ fn run( w.set_sensors(false); w.attached = None; } - Ok(Ctl::Pin(id)) => { + Ok(Ctl::Pin(key)) => { let before = w.active_id(); - w.pinned = id; + w.pinned = key; if w.active_id() != before { w.flush_held(); if w.attached.is_some() { diff --git a/clients/windows/src/input.rs b/clients/windows/src/input.rs index 71e4e720..b715bbd6 100644 --- a/clients/windows/src/input.rs +++ b/clients/windows/src/input.rs @@ -90,7 +90,7 @@ static KBD_HOOK: AtomicIsize = AtomicIsize::new(0); static MOUSE_HOOK: AtomicIsize = AtomicIsize::new(0); /// Mirror of `State::captured` for lock-free reads off the UI thread (the HUD poll). static CAPTURED: AtomicBool = AtomicBool::new(false); -/// Live stats-overlay visibility. Seeded from `Settings::show_hud` at `install`, then toggled by +/// Live stats-overlay visibility. Seeded from `Settings::show_stats` at `install`, then toggled by /// Ctrl+Alt+Shift+S for the session (parity with the GTK client's live `s` toggle); the HUD poll /// reads it lock-free to drive the overlay. static HUD_VISIBLE: AtomicBool = AtomicBool::new(false); @@ -126,16 +126,16 @@ fn set_captured(st: &mut State, on: bool) { /// Install the hooks for a streaming session. Call from the UI thread once the window is shown. /// `inhibit_shortcuts` forwards system shortcuts (Alt+Tab, Win, …) to the host; off = local. -/// `show_hud` seeds the stats-overlay visibility that Ctrl+Alt+Shift+S then toggles live. +/// `show_stats` seeds the stats-overlay visibility that Ctrl+Alt+Shift+S then toggles live. /// `stop` is the session's stop flag, tripped by the disconnect shortcut. pub fn install( connector: Arc, mode: Mode, inhibit_shortcuts: bool, - show_hud: bool, + show_stats: bool, stop: Arc, ) { - HUD_VISIBLE.store(show_hud, Ordering::Relaxed); + HUD_VISIBLE.store(show_stats, Ordering::Relaxed); let hwnd = unsafe { GetForegroundWindow() }; let mut st = State { connector, diff --git a/clients/windows/src/main.rs b/clients/windows/src/main.rs index 2c827f77..0ee3f106 100644 --- a/clients/windows/src/main.rs +++ b/clients/windows/src/main.rs @@ -39,6 +39,8 @@ mod render; #[cfg(windows)] mod session; #[cfg(windows)] +mod shell_window; +#[cfg(windows)] mod spawn; #[cfg(windows)] mod trust; @@ -58,6 +60,7 @@ fn main() { 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( @@ -100,6 +103,27 @@ fn main() { } } +/// 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 --connect host[:port] …`: connect from the CLI, count frames, print stats — the /// Windows analogue of `punktfunk-probe`. #[cfg(windows)] @@ -192,6 +216,7 @@ fn run_headless_cli(args: &[String], identity: (String, String)) { port, fp_hex: trust::hex(&fp), paired: true, + last_used: None, mac: Vec::new(), }); let _ = k.save(); diff --git a/clients/windows/src/shell_window.rs b/clients/windows/src/shell_window.rs new file mode 100644 index 00000000..ffe73e83 --- /dev/null +++ b/clients/windows/src/shell_window.rs @@ -0,0 +1,55 @@ +//! Hide/restore the shell's top-level window around a spawned session, so exactly ONE +//! Punktfunk window is visible at a time: the spawned stream/browse window IS the app +//! while it runs (hidden = no taskbar entry, no Alt-Tab ghost), and the shell reappears +//! the moment the child exits — every exit path funnels through the spawn reader's +//! `Exited` event (clean end, error, crash, Disconnect kill), so the shell can never stay +//! hidden with no child. +//! +//! windows-reactor exposes no window handle, so the HWND is resolved by its (unique) +//! title and cached — the same pattern as `app::apply_window_icon_when_ready` and +//! `stream::window_dpi`. + +use std::sync::atomic::{AtomicIsize, Ordering}; +use windows::Win32::Foundation::HWND; +use windows::Win32::UI::WindowsAndMessaging::{ + FindWindowW, IsWindow, SetForegroundWindow, ShowWindow, SW_HIDE, SW_SHOW, +}; + +static SHELL_HWND: AtomicIsize = AtomicIsize::new(0); + +fn shell_hwnd() -> Option { + unsafe { + let cached = SHELL_HWND.load(Ordering::Relaxed); + if cached != 0 { + let h = HWND(cached as *mut _); + if IsWindow(Some(h)).as_bool() { + return Some(h); + } + } + let h = FindWindowW(None, windows::core::w!("Punktfunk")).ok()?; + SHELL_HWND.store(h.0 as isize, Ordering::Relaxed); + Some(h) + } +} + +/// Hide the shell while a spawned session window is up. Called on the child's +/// `{"ready":true}` (its window has presented — never earlier, so a failed connect keeps +/// the shell in view with its error banner). +pub(crate) fn hide() { + if let Some(h) = shell_hwnd() { + unsafe { + let _ = ShowWindow(h, SW_HIDE); + } + } +} + +/// Bring the shell back (and to the foreground) when the child exits. Safe to call when +/// it was never hidden — showing a visible window is a no-op. +pub(crate) fn restore() { + if let Some(h) = shell_hwnd() { + unsafe { + let _ = ShowWindow(h, SW_SHOW); + let _ = SetForegroundWindow(h); + } + } +} diff --git a/clients/windows/src/spawn.rs b/clients/windows/src/spawn.rs index b7f8f1b5..72ea19c1 100644 --- a/clients/windows/src/spawn.rs +++ b/clients/windows/src/spawn.rs @@ -7,9 +7,9 @@ //! `{"ready":true}`, banner from the `{"error"|"ended": …}` line, `trust_rejected` //! routed to the re-pair PIN ceremony, `stats:` lines to the session status page. //! -//! The legacy in-process D3D11VA presenter remains reachable via the Settings -//! "Streaming engine" pick or `PUNKTFUNK_BUILTIN_STREAM=1` (`app::use_builtin_stream`) — -//! the A/B baseline until its deletion. +//! The legacy in-process D3D11VA presenter remains reachable via the +//! `PUNKTFUNK_BUILTIN_STREAM=1` env override (`app::use_builtin_stream`) — the +//! developer A/B baseline until its deletion. use std::io::BufRead as _; use std::process::{Child, Command, Stdio}; @@ -86,34 +86,75 @@ pub(crate) fn session_binary() -> std::path::PathBuf { /// Spawn the session binary for a connect with `fp_hex` pinned and feed its lifecycle to /// `on_event` from a reader thread. The child is parked in `slot` so Disconnect/Cancel -/// can kill it. `Err` = the spawn itself failed (binary missing?) — surfaced as a -/// connect error by the caller. +/// can kill it. `fullscreen` starts the stream window fullscreen (the Settings "Start +/// streams fullscreen" toggle); `launch` carries a library title id for the host to +/// launch during the handshake. `Err` = the spawn itself failed (binary missing?) — +/// surfaced as a connect error by the caller. pub(crate) fn spawn_session( addr: &str, port: u16, fp_hex: &str, connect_timeout_secs: u64, + fullscreen: bool, + launch: Option<&str>, slot: SessionChild, - mut on_event: impl FnMut(SpawnEvent) + Send + 'static, + on_event: impl FnMut(SpawnEvent) + Send + 'static, ) -> Result<(), String> { - use std::os::windows::process::CommandExt as _; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - let mut cmd = Command::new(session_binary()); cmd.arg("--connect") .arg(format!("{addr}:{port}")) .arg("--fp") .arg(fp_hex) .arg("--connect-timeout") - .arg(connect_timeout_secs.to_string()) - .stdin(Stdio::null()) + .arg(connect_timeout_secs.to_string()); + if fullscreen { + cmd.arg("--fullscreen"); + } + if let Some(id) = launch { + cmd.arg("--launch").arg(id); + } + spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event) +} + +/// Spawn the session binary in `--browse` mode: the console (gamepad) library for a +/// PAIRED host, in the session window — launches run as streams in that same window. +/// The same stdout contract as a connect (`--json-status`): `ready` when the library +/// window presents, `error` on a failed start, EOF on quit. +pub(crate) fn spawn_browse( + addr: &str, + port: u16, + fullscreen: bool, + slot: SessionChild, + on_event: impl FnMut(SpawnEvent) + Send + 'static, +) -> Result<(), String> { + let mut cmd = Command::new(session_binary()); + cmd.arg("--browse") + .arg(format!("{addr}:{port}")) + .arg("--json-status"); + if fullscreen { + cmd.arg("--fullscreen"); + } + spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event) +} + +/// The shared spawn + stdout-contract reader behind [`spawn_session`]/[`spawn_browse`]. +fn spawn_with( + mut cmd: Command, + host_label: &str, + slot: SessionChild, + mut on_event: impl FnMut(SpawnEvent) + Send + 'static, +) -> Result<(), String> { + use std::os::windows::process::CommandExt as _; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + cmd.stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) // session logs interleave with the shell's (dev runs) .creation_flags(CREATE_NO_WINDOW); let mut child = cmd .spawn() .map_err(|e| format!("couldn't start punktfunk-session: {e}"))?; - tracing::info!(host = %addr, port, "session binary spawned"); + tracing::info!(host = %host_label, "session binary spawned"); let stdout = child.stdout.take().expect("piped stdout"); // Park the child where the kill handle (and the reader, for the final reap) reach it. diff --git a/clients/windows/src/trust.rs b/clients/windows/src/trust.rs index 2843d43b..0408ba16 100644 --- a/clients/windows/src/trust.rs +++ b/clients/windows/src/trust.rs @@ -1,256 +1,14 @@ -//! Client identity, the known-hosts (pinned fingerprint) store, and app settings. +//! Client identity, the known-hosts (pinned fingerprint) store, and app settings — +//! re-exported from `pf-client-core` so the shell and the spawned `punktfunk-session` +//! binary share ONE `Settings`/`KnownHosts` shape over the same files +//! (`%APPDATA%\punktfunk\client-windows-settings.json` / `client-known-hosts.json`). //! -//! Ported near-verbatim from the GTK Linux client; the only platform change is the config -//! directory — `%APPDATA%\punktfunk` (the Windows analogue of `~/.config/punktfunk`), shared -//! with the Windows host's identity location. The identity files (`client-{cert,key}.pem`) -//! keep the same names so the trust model is identical across the native clients. +//! The shell is the settings file's only writer; the session only reads it. The shell's +//! former private `Settings` copy (≤ 0.8.4: `show_hud`, `engine`) is gone — old files +//! still load via a serde alias in core, and the legacy in-process presenter is now +//! reachable only through `PUNKTFUNK_BUILTIN_STREAM=1` (see `app::use_builtin_stream`). -use anyhow::{anyhow, Context, Result}; -use punktfunk_core::quic::endpoint; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -pub fn config_dir() -> Result { - let appdata = std::env::var("APPDATA").context("APPDATA unset")?; - Ok(PathBuf::from(appdata).join("punktfunk")) -} - -/// This client's persistent identity, generated on first use — presented on every connect -/// so hosts can recognize it once paired. -pub fn load_or_create_identity() -> Result<(String, String)> { - let dir = config_dir()?; - let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem")); - if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) { - return Ok((c, k)); - } - let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?; - std::fs::create_dir_all(&dir)?; - std::fs::write(&cp, &c)?; - std::fs::write(&kp, &k)?; - tracing::info!(cert = %cp.display(), "generated client identity"); - Ok((c, k)) -} - -pub fn hex(fp: &[u8; 32]) -> String { - fp.iter().map(|b| format!("{b:02x}")).collect() -} - -pub fn parse_hex32(s: &str) -> Option<[u8; 32]> { - if s.len() != 64 { - return None; - } - let mut out = [0u8; 32]; - for (i, b) in out.iter_mut().enumerate() { - *b = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?; - } - Some(out) -} - -/// One trusted host: its pinned certificate fingerprint plus how we got there (TOFU or a -/// PIN ceremony) and where we last reached it. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct KnownHost { - pub name: String, - pub addr: String, - pub port: u16, - /// SHA-256 of the host certificate, lowercase hex — the pin for every later connect. - pub fp_hex: String, - /// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use). - pub paired: bool, - /// Wake-on-LAN MAC(s) (`aa:bb:cc:dd:ee:ff`) learned from the host's mDNS `mac` TXT while it was - /// online, so we can wake it once it sleeps. `default` so pre-existing stores load; empty until - /// first learned. - #[serde(default)] - pub mac: Vec, -} - -#[derive(Default, Serialize, Deserialize)] -pub struct KnownHosts { - pub hosts: Vec, -} - -impl KnownHosts { - fn path() -> Result { - Ok(config_dir()?.join("client-known-hosts.json")) - } - - pub fn load() -> KnownHosts { - Self::path() - .and_then(|p| Ok(std::fs::read_to_string(p)?)) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default() - } - - pub fn save(&self) -> Result<()> { - let p = Self::path()?; - std::fs::create_dir_all(p.parent().unwrap())?; - std::fs::write(&p, serde_json::to_string_pretty(self)?)?; - Ok(()) - } - - pub fn find_by_fp(&self, fp_hex: &str) -> Option<&KnownHost> { - self.hosts.iter().find(|h| h.fp_hex == fp_hex) - } - - /// Forget a host (the hosts page's "Forget" action): drops the pinned fingerprint, so a - /// later connect goes back through pairing/TOFU. - pub fn remove_by_fp(&mut self, fp_hex: &str) { - self.hosts.retain(|h| h.fp_hex != fp_hex); - } - - pub fn find_by_addr(&self, addr: &str, port: u16) -> Option<&KnownHost> { - self.hosts.iter().find(|h| h.addr == addr && h.port == port) - } - - /// Insert or refresh an entry, keyed by fingerprint. `paired` only ever upgrades - /// (a later TOFU connect must not demote a PIN-paired host). - pub fn upsert(&mut self, entry: KnownHost) { - if let Some(h) = self.hosts.iter_mut().find(|h| h.fp_hex == entry.fp_hex) { - h.name = entry.name; - h.addr = entry.addr; - h.port = entry.port; - h.paired |= entry.paired; - // A trust-decision upsert (which carries no MAC) must not wipe learned MACs. - if !entry.mac.is_empty() { - h.mac = entry.mac; - } - } else { - self.hosts.push(entry); - } - } -} - -/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host is -/// online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so the -/// hosts page can call it on every discovery tick without churning the store. -pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) { - if mac.is_empty() { - return; - } - let mut known = KnownHosts::load(); - let Some(h) = known - .hosts - .iter_mut() - .find(|h| (!fp_hex.is_empty() && h.fp_hex == fp_hex) || (h.addr == addr && h.port == port)) - else { - return; - }; - if h.mac == mac { - return; - } - h.mac = mac.to_vec(); - let _ = known.save(); -} - -/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file -/// stays readable; parsed with `*Pref::from_name` at connect time. -#[derive(Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct Settings { - /// Stream mode; `0` = the native size/refresh of the monitor the window is on, - /// resolved at connect time. - pub width: u32, - pub height: u32, - pub refresh_hz: u32, - /// Requested encoder bitrate (kbps); 0 = host default. - pub bitrate_kbps: u32, - pub gamepad: String, - /// Which host compositor backend to request (advisory; the host falls back to - /// auto-detect when unavailable). - pub compositor: String, - /// Grab system shortcuts (Alt+Tab, Win…) while input is captured. - pub inhibit_shortcuts: bool, - /// Stream the default microphone to the host's virtual mic source. - pub mic_enabled: bool, - /// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it - /// can capture; the resolved count drives the decoder + WASAPI render layout. - pub audio_channels: u8, - /// Advertise 10-bit + HDR10 so the host upgrades HDR content to a Main10/PQ stream (the client - /// presents it on a 10-bit ST.2084 swapchain). No effect on SDR content. - pub hdr_enabled: bool, - /// Video decode backend: `auto` (D3D11VA, fall back to software), `hardware`, or `software`. - pub decoder: String, - /// Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, or `"av1"`. A soft - /// preference — the host honors it when it can emit it, else falls back to the best shared codec. - #[serde(default = "default_codec")] - pub codec: String, - /// Decode/present GPU: the DXGI adapter description to prefer on a multi-GPU box; empty = - /// automatic (the adapter driving the window's monitor). Applies from the next session; a - /// vanished adapter (eGPU unplugged) falls back to automatic. - #[serde(default)] - pub adapter: String, - /// Show the stats/info overlay (HUD) over the stream. - #[serde(default = "default_true")] - pub show_hud: bool, - /// Streaming engine: `""` = the punktfunk-session Vulkan window (the default), - /// `"builtin"` = the legacy in-process D3D11VA presenter. A temporary A/B knob — - /// removed with the legacy path once the Vulkan session is fully validated. - /// `default` so pre-existing stores load. - #[serde(default)] - pub engine: String, -} - -fn default_codec() -> String { - "auto".into() -} - -fn default_true() -> bool { - true -} - -impl Settings { - /// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto). - pub fn preferred_codec(&self) -> u8 { - match self.codec.as_str() { - "h264" | "avc" => punktfunk_core::quic::CODEC_H264, - "hevc" | "h265" => punktfunk_core::quic::CODEC_HEVC, - "av1" => punktfunk_core::quic::CODEC_AV1, - _ => 0, - } - } -} - -impl Default for Settings { - fn default() -> Self { - Settings { - width: 0, - height: 0, - refresh_hz: 0, - bitrate_kbps: 0, - gamepad: "auto".into(), - compositor: "auto".into(), - inhibit_shortcuts: true, - mic_enabled: false, - audio_channels: 2, - hdr_enabled: true, - decoder: "auto".into(), - codec: "auto".into(), - adapter: String::new(), - show_hud: true, - engine: String::new(), - } - } -} - -impl Settings { - fn path() -> Result { - Ok(config_dir()?.join("client-windows-settings.json")) - } - - pub fn load() -> Settings { - Self::path() - .and_then(|p| Ok(std::fs::read_to_string(p)?)) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default() - } - - pub fn save(&self) { - let Ok(p) = Self::path() else { return }; - let _ = std::fs::create_dir_all(p.parent().unwrap()); - if let Ok(s) = serde_json::to_string_pretty(self) { - let _ = std::fs::write(&p, s); - } - } -} +pub use pf_client_core::trust::{ + hex, learn_mac, load_or_create_identity, parse_hex32, touch_last_used, KnownHost, KnownHosts, + Settings, +}; diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 2a614ab2..9a7bd335 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -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 { // 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); } diff --git a/crates/pf-presenter/Cargo.toml b/crates/pf-presenter/Cargo.toml index 87782d66..4e86564c 100644 --- a/crates/pf-presenter/Cargo.toml +++ b/crates/pf-presenter/Cargo.toml @@ -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", +] } diff --git a/crates/pf-presenter/src/lib.rs b/crates/pf-presenter/src/lib.rs index 509177ec..b4d1536d 100644 --- a/crates/pf-presenter/src/lib.rs +++ b/crates/pf-presenter/src/lib.rs @@ -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}; diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index c25a8787..86860915 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -226,6 +226,10 @@ impl StreamState { } fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result> { + // 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 } 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() { diff --git a/crates/pf-presenter/src/win32.rs b/crates/pf-presenter/src/win32.rs new file mode 100644 index 00000000..db20fd43 --- /dev/null +++ b/crates/pf-presenter/src/win32.rs @@ -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); + } + } + } +}