Files
punktfunk/clients/windows/src/app/library.rs
T
enricobuehler f4850625bd
apple / swift (push) Successful in 1m10s
android / android (push) Successful in 4m56s
arch / build-publish (push) Successful in 5m56s
audit / bun-audit (push) Successful in 13s
audit / cargo-audit (push) Successful in 1m34s
ci / web (push) Successful in 1m11s
windows-host / package (push) Successful in 7m57s
ci / docs-site (push) Successful in 1m27s
release / apple (push) Successful in 8m39s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m26s
ci / bench (push) Successful in 5m0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m16s
apple / screenshots (push) Successful in 5m35s
ci / rust (push) Successful in 10m32s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m47s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m36s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 6m38s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m39s
flatpak / build-publish (push) Successful in 5m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m33s
docker / deploy-docs (push) Successful in 18s
feat(clients/windows): game library page + session window placement + help polish
UX polish batch 2 (plan workstreams D/E5/E6):

- D: a mouse/keyboard game library page over pf-client-core::library (the GTK
  ui_library.rs counterpart): responsive 2-6 column poster grid (2:3 portraits,
  store badge, monogram placeholder while art streams in), loading / error+retry
  / empty / grid states, tap-to-play via a normal spawn carrying --launch id.
  Poster bytes land in a disk cache (%LOCALAPPDATA%\punktfunk\art-cache) so the
  Image widget loads file:/// URIs (reactor has no from-bytes source) and
  revisits render instantly. Reached from a paired host's "Browse library..."
  menu item behind the new "Show game library (experimental)" Settings toggle
  (the shared library_enabled field, Apple/GTK parity). A Shared::library_gen
  generation guard keeps a superseded fetch from publishing (speed-test pattern).

- E5: the session window opens at the shell's own top-left (--window-pos, new
  SessionOpts::window_pos) instead of centered on the primary display - streams
  land on the monitor the shell is on, and the hide/restore handoff reads as one
  window changing content. Fullscreen follows that display.

- E6: the Help shortcuts add Alt+Enter and the controller escape chord
  (LB+RB+Start+Back, hold to disconnect).

Also: rustfmt normalization of the merged probe helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 00:43:46 +02:00

356 lines
13 KiB
Rust

//! The game library page (mouse/keyboard): the target host's library as a responsive
//! poster grid over `pf-client-core::library` — the WinUI counterpart of the GTK shell's
//! `ui_library.rs`, sharing its service layer (mTLS fetch against the host's management
//! API, pre-classified errors, the 3-worker art pipeline) and its four states
//! (loading / error+retry / empty / grid). Reached from a paired host's "…" menu
//! ("Browse library…", behind the Settings "Show game library" experimental toggle);
//! picking a title starts a normal stream carrying `--launch id` — the host launches the
//! app during the connect handshake.
//!
//! Poster bytes land in a small disk cache (`%LOCALAPPDATA%\punktfunk\art-cache`) and the
//! `Image` widget loads `file:///` URIs from it — reactor's `ImageSource` has no
//! from-bytes constructor, and the cache makes revisits (and offline browsing) instant.
//!
//! Re-render discipline: the fetch and the art stream complete on worker threads, so the
//! whole [`LibraryState`] lives in ROOT state (see the app module docs) and arrives here
//! as a prop; `Shared::library_gen` invalidates a superseded fetch exactly like the
//! speed test's generation guard.
use super::connect::initiate_launch;
use super::style::*;
use super::{AppCtx, Screen, Svc};
use pf_client_core::library;
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::sync::Arc;
use windows_reactor::*;
/// Poster-grid metrics: minimum poster-column width before dropping a column, the gap,
/// and the 2:3 portrait ratio.
const POSTER_MIN_WIDTH: f64 = 150.0;
const POSTER_GAP: f64 = 12.0;
const POSTER_RATIO: f64 = 1.5;
/// One game, as the grid renders it (the wire `GameEntry` minus the artwork paths, which
/// resolve into `LibraryState::art`).
#[derive(Clone, PartialEq)]
pub(crate) struct Game {
pub(crate) id: String,
pub(crate) title: String,
pub(crate) store: String,
}
#[derive(Clone, PartialEq, Default)]
pub(crate) enum LibraryPhase {
#[default]
Loading,
Failed(String),
Empty,
Ready(Vec<Game>),
}
/// The page's whole thread-driven state: the fetch phase plus poster art as it lands
/// (game id → `file:///` URI into the disk cache).
#[derive(Clone, PartialEq, Default)]
pub(crate) struct LibraryState {
pub(crate) phase: LibraryPhase,
pub(crate) art: HashMap<String, String>,
}
/// Props for the library page: the services plus the fetch/art state driving re-render.
#[derive(Clone)]
pub(crate) struct LibraryProps {
pub(crate) svc: Svc,
pub(crate) state: LibraryState,
}
impl PartialEq for LibraryProps {
fn eq(&self, other: &Self) -> bool {
self.svc == other.svc && self.state == other.state
}
}
/// Fetch the library for `Shared::target` off the UI thread, publishing into root state:
/// phase first, then art entries as the workers stream them in. A newer call (re-open,
/// Retry, another host) bumps `Shared::library_gen`, and a superseded worker stops
/// publishing — the speed test's generation pattern.
pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<LibraryState>) {
let target = ctx.shared.target.lock().unwrap().clone();
let generation = ctx.shared.library_gen.fetch_add(1, Ordering::SeqCst) + 1;
set_library.call(LibraryState::default()); // Loading, no art
let (shared, identity, set) = (
ctx.shared.clone(),
ctx.identity.clone(),
set_library.clone(),
);
std::thread::Builder::new()
.name("pf-library".into())
.spawn(move || {
let pin = target.fp_hex.as_deref().and_then(crate::trust::parse_hex32);
let publish = |state: &LibraryState| {
if shared.library_gen.load(Ordering::SeqCst) == generation {
set.call(state.clone());
}
};
let mut state = LibraryState::default();
let games = match library::fetch_games(
&target.addr,
library::DEFAULT_MGMT_PORT,
&identity,
pin,
) {
Ok(games) => games,
Err(e) => {
state.phase = LibraryPhase::Failed(e.to_string());
return publish(&state);
}
};
if games.is_empty() {
state.phase = LibraryPhase::Empty;
return publish(&state);
}
// Seed cached posters; queue the art pipeline for the rest.
let base = library::base_url(&target.addr, library::DEFAULT_MGMT_PORT);
let cache = art_cache_dir();
let mut jobs: VecDeque<(String, Vec<String>)> = VecDeque::new();
for g in &games {
match cache.as_deref().and_then(|d| cached_art_uri(d, &g.id)) {
Some(uri) => {
state.art.insert(g.id.clone(), uri);
}
None => {
let candidates = g.art.poster_candidates(&base);
if !candidates.is_empty() {
jobs.push_back((g.id.clone(), candidates));
}
}
}
}
state.phase = LibraryPhase::Ready(
games
.iter()
.map(|g| Game {
id: g.id.clone(),
title: g.title.clone(),
store: g.store.clone(),
})
.collect(),
);
publish(&state);
if jobs.is_empty() {
return;
}
let rx = library::spawn_art_fetch(base, identity, pin, jobs);
while let Ok((id, bytes)) = rx.recv_blocking() {
if shared.library_gen.load(Ordering::SeqCst) != generation {
return; // superseded — stop touching state (and the cache is shared anyway)
}
if let Some(uri) = cache.as_deref().and_then(|d| store_art(d, &id, &bytes)) {
state.art.insert(id, uri);
publish(&state);
}
}
})
.ok();
}
/// `%LOCALAPPDATA%\punktfunk\art-cache` — poster bytes by game id, so the `Image` widget
/// has a `file:///` URI to load and revisits skip the network entirely. Small (one poster
/// per library entry); no eviction yet.
fn art_cache_dir() -> Option<PathBuf> {
let base = std::env::var_os("LOCALAPPDATA")?;
Some(PathBuf::from(base).join("punktfunk").join("art-cache"))
}
/// The cache filename for a game id (`steam:570` → `steam_570.img`) — ids are short and
/// store-qualified, so the sanitized form stays unique in practice.
fn art_file(dir: &Path, id: &str) -> PathBuf {
let safe: String = id
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
dir.join(format!("{safe}.img"))
}
fn cached_art_uri(dir: &Path, id: &str) -> Option<String> {
let p = art_file(dir, id);
p.exists().then(|| file_uri(&p))
}
fn store_art(dir: &Path, id: &str, bytes: &[u8]) -> Option<String> {
std::fs::create_dir_all(dir).ok()?;
let p = art_file(dir, id);
std::fs::write(&p, bytes).ok()?;
Some(file_uri(&p))
}
fn file_uri(p: &Path) -> String {
format!("file:///{}", p.display().to_string().replace('\\', "/"))
}
/// The store badge text — shared spelling with the GTK page and the console UI's posters.
fn store_label(store: &str) -> &'static str {
match store {
"steam" => "Steam",
"custom" => "Custom",
"heroic" => "Heroic",
"lutris" => "Lutris",
"epic" => "Epic",
"gog" => "GOG",
"xbox" => "Xbox",
_ => "Game",
}
}
/// Monogram for the placeholder poster: the first letters of the first two words.
fn initials(title: &str) -> String {
title
.split_whitespace()
.take(2)
.filter_map(|w| w.chars().next())
.flat_map(char::to_uppercase)
.collect()
}
/// One poster tile: the artwork (or a monogram placeholder while it loads) with the store
/// badge overlaid top-left, the title below, tap-to-launch across the whole tile.
fn poster_tile(
game: &Game,
art_uri: Option<&str>,
poster_h: f64,
on_tap: Box<dyn Fn()>,
) -> Element {
let poster: Element = match art_uri {
Some(uri) => Image::new_with_uri(uri)
.stretch(Stretch::UniformToFill)
.height(poster_h)
.into(),
None => border(
text_block(initials(&game.title))
.font_size(28.0)
.semibold()
.foreground(ThemeRef::SecondaryText)
.horizontal_alignment(HorizontalAlignment::Center)
.vertical_alignment(VerticalAlignment::Center),
)
.background(ThemeRef::SubtleFill)
.height(poster_h)
.into(),
};
let framed = border(grid(vec![
poster,
pill(store_label(&game.store), Pill::Neutral)
.horizontal_alignment(HorizontalAlignment::Left)
.vertical_alignment(VerticalAlignment::Top)
.margin(uniform(6.0))
.into(),
]))
.corner_radius(8.0)
.border_brush(ThemeRef::CardStroke)
.border_thickness(uniform(1.0));
border(
vstack((
framed,
text_block(&game.title)
.font_size(12.0)
.wrap()
.margin(edges(2.0, 6.0, 2.0, 0.0)),
))
.spacing(0.0),
)
.background(hit_test_backstop())
.on_tapped(on_tap)
.into()
}
pub(crate) fn library_page(props: &LibraryProps, cx: &mut RenderCx) -> Element {
let ctx = &props.svc.ctx;
let target = ctx.shared.target.lock().unwrap().clone();
let (ss, st) = (props.svc.set_screen.clone(), props.svc.set_status.clone());
// Responsive poster columns from the live window width (the hosts page's pattern).
let window = cx.use_inner_size();
let content_w = (window.width - 64.0).clamp(POSTER_MIN_WIDTH, 1120.0);
let cols =
(((content_w + POSTER_GAP) / (POSTER_MIN_WIDTH + POSTER_GAP)).floor() as usize).clamp(2, 6);
let tile_w = (content_w - POSTER_GAP * (cols as f64 - 1.0)) / cols as f64;
let poster_h = tile_w * POSTER_RATIO;
let back_btn = button("Back").icon(Symbol::Back).on_click({
let ss = ss.clone();
move || ss.call(Screen::Hosts)
});
let title = if target.name.is_empty() {
"Game library".to_string()
} else {
format!("Game library \u{00B7} {}", target.name)
};
let mut body: Vec<Element> = vec![page_header(&title, back_btn)];
match &props.state.phase {
LibraryPhase::Loading => body.push(
card(
hstack((
ProgressRing::indeterminate().width(18.0).height(18.0),
text_block("Loading the library\u{2026}").foreground(ThemeRef::SecondaryText),
))
.spacing(12.0),
)
.into(),
),
LibraryPhase::Failed(msg) => {
body.push(
InfoBar::new("Couldn't load the library")
.message(msg.clone())
.error()
.is_closable(false)
.into(),
);
let (ctx2, set_library) = (ctx.clone(), props.svc.set_library.clone());
body.push(
button("Retry")
.accent()
.icon(Symbol::Refresh)
.on_click(move || start_fetch(&ctx2, &set_library))
.horizontal_alignment(HorizontalAlignment::Left)
.into(),
);
}
LibraryPhase::Empty => body.push(
card(
text_block(
"No games yet \u{2014} add titles to the host's library (its console or web \
UI) and they appear here.",
)
.wrap()
.foreground(ThemeRef::SecondaryText),
)
.into(),
),
LibraryPhase::Ready(games) => {
let tiles: Vec<Element> = games
.iter()
.map(|g| {
let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone());
let (target, id) = (target.clone(), g.id.clone());
poster_tile(
g,
props.state.art.get(&g.id).map(String::as_str),
poster_h,
Box::new(move || {
initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st)
}),
)
})
.collect();
body.push(tile_grid(tiles, cols, POSTER_GAP));
}
}
page_wide(body)
}