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
+37 -19
View File
@@ -80,9 +80,9 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
let connector_ref = cx.use_ref::<Option<Arc<NativeClient>>>(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<super::AppCtx>, 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<super::AppCtx>, 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<super::AppCtx>, hud: &HudSample) -> Element
// a chip row (the decode path gets the status colour), the rest dim stage lines.
let mut body: Vec<Element> = 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<super::AppCtx>, 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<super::AppCtx>, 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()
}