diff --git a/Cargo.lock b/Cargo.lock index 235d9062..5fee7430 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2794,6 +2794,7 @@ dependencies = [ "ash", "pf-client-core", "pf-presenter", + "punktfunk-core", "sdl3", "skia-safe", "tracing", diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index f02e50c4..3b81fa4a 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -591,7 +591,9 @@ pub fn run() -> glib::ExitCode { // Streams and the console library live in the session binary now — exec it, // forwarding the relevant argv (the Decky wrapper keeps working through the shell // until it's repointed). - if crate::cli::arg_value("--connect").is_some() || crate::cli::arg_value("--browse").is_some() { + // `--browse` may be bare now (the console home — hosts, pairing, settings), so the + // gate is the flag, not a value after it. + if crate::cli::arg_value("--connect").is_some() || crate::cli::arg_flag("--browse") { return crate::cli::exec_session(); } diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index 570a1394..e4dc33b8 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -34,7 +34,7 @@ pub fn arg_value(flag: &str) -> Option { } /// True if argv contains `flag` (a valueless switch). -fn arg_flag(flag: &str) -> bool { +pub fn arg_flag(flag: &str) -> bool { std::env::args().any(|a| a == flag) } diff --git a/clients/session/src/browse.rs b/clients/session/src/browse.rs deleted file mode 100644 index 41b91a46..00000000 --- a/clients/session/src/browse.rs +++ /dev/null @@ -1,197 +0,0 @@ -//! `--browse host[:port]` — the console game library (phase 4b of the plan): the Skia -//! coverflow idles in the session window, A launches the focused title as a stream in -//! the SAME window (no gamescope window handoff — the whole point of one process), the -//! session's end returns to the library, B quits to Gaming Mode. -//! -//! The host must already be paired (the stored pin fetches the library and connects -//! silently; no ceremony can run under gamescope) — an unpaired target renders the -//! pair-first scene. `PUNKTFUNK_FAKE_LIBRARY=` feeds canned entries with no -//! host (portrait paths starting with `/` load from disk), the GPU-only dev path. - -use crate::session_main::{ - arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, window_pos, -}; -use pf_client_core::{library, trust}; -use pf_console_ui::{LibraryGame, LibraryPhase, LibraryShared, SkiaOverlay}; -use pf_presenter::overlay::OverlayAction; -use pf_presenter::ActionOutcome; -use std::collections::VecDeque; - -pub fn run(target: &str) -> u8 { - let (addr, port) = parse_host_port(target); - let known = trust::KnownHosts::load(); - let k = known - .hosts - .iter() - .find(|h| h.addr == addr && h.port == port); - let host_label = k.map_or_else(|| addr.clone(), |h| h.name.clone()); - let paired = k.is_some_and(|h| h.paired); - let pin = k.and_then(|h| trust::parse_hex32(&h.fp_hex)); - let mgmt = arg_value("--mgmt") - .and_then(|p| p.parse().ok()) - .unwrap_or(library::DEFAULT_MGMT_PORT); - - let identity = match trust::load_or_create_identity() { - Ok(i) => i, - Err(e) => { - eprintln!("client identity: {e:#}"); - return crate::session_main::EXIT_CONNECT_FAILED; - } - }; - let settings = trust::Settings::load(); - - let (overlay, shared) = match SkiaOverlay::with_library(host_label.clone()) { - Ok(v) => v, - Err(e) => { - eprintln!("console UI: {e:#}"); - return crate::session_main::EXIT_PRESENTER_FAILED; - } - }; - - // The library fetch — paired hosts only (the fake-library hook exists precisely for - // host-less/pairing-less UI work). - let fake = std::env::var_os("PUNKTFUNK_FAKE_LIBRARY").is_some(); - if paired || fake { - spawn_fetch(shared.clone(), addr.clone(), mgmt, identity.clone(), pin); - } else { - 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(), - window_pos: window_pos(), - print_stats: settings.show_stats || arg_flag("--stats"), - json_status, - on_connected: Some(Box::new(|fingerprint: [u8; 32]| { - trust::touch_last_used(&trust::hex(&fingerprint)); - })), - overlay: Some(Box::new(overlay)), - }; - - let result = - pf_presenter::run_browse(opts, |action, gamepad, native, force_software, vulkan| { - match action { - OverlayAction::Launch { id, title } => { - // The carousel only renders for a paired host, so the pin exists; the - // guard keeps a logic slip from turning into a pinless connect. - let Some(pin) = pin else { - tracing::warn!("launch without a stored pin — refusing"); - return ActionOutcome::Handled; - }; - tracing::info!(%id, %title, "launching from the library"); - ActionOutcome::Start(Box::new(session_params( - &settings, - addr.clone(), - port, - pin, - identity.clone(), - Some(id), - gamepad, - native, - force_software, - vulkan, - ))) - } - OverlayAction::Retry => { - spawn_fetch(shared.clone(), addr.clone(), mgmt, identity.clone(), pin); - ActionOutcome::Handled - } - OverlayAction::Quit => ActionOutcome::Quit, - } - }); - - 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 - } - } -} - -/// Fetch the library off the main thread, then stream poster art into the shared model -/// as results land (the GTK launcher's `load` + `load_art`, minus the main-loop hops — -/// the renderer drains `push_art` per frame). -fn spawn_fetch( - shared: LibraryShared, - addr: String, - mgmt: u16, - identity: (String, String), - pin: Option<[u8; 32]>, -) { - shared.set_phase(LibraryPhase::Loading); - std::thread::Builder::new() - .name("punktfunk-library".into()) - .spawn(move || { - if let Ok(path) = std::env::var("PUNKTFUNK_FAKE_LIBRARY") { - load_fake(&shared, &path); - return; - } - match library::fetch_games(&addr, mgmt, &identity, pin) { - Ok(games) => { - let base = library::base_url(&addr, mgmt); - let jobs: VecDeque<(String, Vec)> = games - .iter() - .map(|g| (g.id.clone(), g.art.poster_candidates(&base))) - .filter(|(_, candidates)| !candidates.is_empty()) - .collect(); - shared.set_games( - games - .iter() - .map(|g| LibraryGame { - id: g.id.clone(), - title: g.title.clone(), - store: g.store.clone(), - }) - .collect(), - ); - if !jobs.is_empty() { - let rx = library::spawn_art_fetch(base, identity, pin, jobs); - while let Ok((id, bytes)) = rx.recv_blocking() { - shared.push_art(id, bytes); - } - } - } - Err(e) => shared.set_phase(LibraryPhase::Error { - title: "Couldn't load the library".into(), - body: e.to_string(), - can_retry: true, - }), - } - }) - .ok(); -} - -/// Dev hook: entries from a JSON file; portrait paths starting with `/` load from disk. -fn load_fake(shared: &LibraryShared, path: &str) { - let games: Vec = std::fs::read_to_string(path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default(); - for g in &games { - if let Some(p) = g.art.portrait.as_deref().filter(|p| p.starts_with('/')) { - if let Ok(bytes) = std::fs::read(p) { - shared.push_art(g.id.clone(), bytes); - } - } - } - shared.set_games( - games - .iter() - .map(|g| LibraryGame { - id: g.id.clone(), - title: g.title.clone(), - store: g.store.clone(), - }) - .collect(), - ); -} diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs new file mode 100644 index 00000000..fcd7d07a --- /dev/null +++ b/clients/session/src/console.rs @@ -0,0 +1,703 @@ +//! `--browse [host[:port]]` — the console shell. Bare `--browse` opens the host list +//! (discovery, pairing, settings, wake — the whole couch flow); with a target it opens +//! straight into that host's library (the Decky per-host launch), B backing out to the +//! list. A launches in the SAME window (no gamescope window handoff — the whole point +//! of one process), the session's end returns to the console, B at the root quits to +//! Gaming Mode. +//! +//! This file is the console's SERVICE side: the shell (pf-console-ui) renders and +//! raises [`ConsoleCmd`]s; worker threads here run everything that blocks — mDNS +//! discovery, reachability probes, the SPAKE2 pairing ceremony, wake-on-LAN loops, +//! library fetches, known-hosts persistence — and write results into the shared +//! models. `PUNKTFUNK_FAKE_LIBRARY=` feeds canned entries with no host +//! (portrait paths starting with `/` load from disk), the GPU-only dev path. + +use crate::session_main::{ + arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, window_pos, +}; +use pf_client_core::gamepad::is_steam_deck; +use pf_client_core::{discovery, library, trust, wol}; +use pf_console_ui::{ + ConsoleCmd, ConsoleEntry, ConsoleHandles, ConsoleOptions, ConsoleShared, HostRow, LibraryGame, + LibraryPhase, LibraryShared, PairPhase, SkiaOverlay, WakeStatus, +}; +use pf_presenter::overlay::OverlayAction; +use pf_presenter::ActionOutcome; +use std::collections::{HashMap, VecDeque}; +use std::net::Ipv4Addr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +pub fn run(target: Option<&str>) -> u8 { + let identity = match trust::load_or_create_identity() { + Ok(i) => i, + Err(e) => { + eprintln!("client identity: {e:#}"); + return crate::session_main::EXIT_CONNECT_FAILED; + } + }; + + // Resolve the entry point: a paired target opens straight into its library; an + // unpaired/unknown one lands on Home with the target seeded into the list (one A + // from pairing). The fake-library hook fabricates a paired host with no network. + let fake = std::env::var_os("PUNKTFUNK_FAKE_LIBRARY").is_some(); + let known = trust::KnownHosts::load(); + let mut seed: Option = None; + let (entry, window_label) = match target { + Some(target) => { + let (addr, port) = parse_host_port(target); + let k = known + .hosts + .iter() + .find(|h| h.addr == addr && h.port == port); + let row = HostRow { + key: k + .filter(|h| !h.fp_hex.is_empty()) + .map_or_else(|| format!("{addr}:{port}"), |h| h.fp_hex.clone()), + name: k + .map(|h| host_display_name(&h.name, &h.addr)) + .unwrap_or_else(|| addr.clone()), + addr: addr.clone(), + port, + fp_hex: k.map(|h| h.fp_hex.clone()).unwrap_or_default(), + paired: k.is_some_and(|h| h.paired) || fake, + saved: k.is_some(), + online: false, + mgmt_port: arg_value("--mgmt") + .and_then(|p| p.parse().ok()) + .unwrap_or(library::DEFAULT_MGMT_PORT), + can_wake: false, + last_used: k.and_then(|h| h.last_used), + }; + let label = row.name.clone(); + if k.is_none() { + seed = Some(row.clone()); + } + if row.paired { + (ConsoleEntry::Library(row), Some(label)) + } else { + (ConsoleEntry::Home, Some(label)) + } + } + None if fake => { + let row = fake_host_row(); + (ConsoleEntry::Library(row), None) + } + None => (ConsoleEntry::Home, None), + }; + let initial_fetch = match &entry { + ConsoleEntry::Library(h) => Some(ConsoleCmd::FetchLibrary { + addr: h.addr.clone(), + mgmt: h.mgmt_port, + fp_hex: h.fp_hex.clone(), + }), + ConsoleEntry::Home => None, + }; + + let opts = ConsoleOptions { + device_name: device_name(), + deck: is_steam_deck(), + }; + let (overlay, handles) = match SkiaOverlay::console(opts, entry) { + Ok(v) => v, + Err(e) => { + eprintln!("console UI: {e:#}"); + return crate::session_main::EXIT_PRESENTER_FAILED; + } + }; + let ConsoleHandles { + console, + library: library_model, + bus, + } = handles; + + // The service loop: discovery, probes, wake, pairing, persistence, fetches. + let service = Service::start( + console.clone(), + library_model.clone(), + bus.clone(), + identity.clone(), + seed, + ); + if let Some(cmd) = initial_fetch { + bus.send(cmd); + } + + // `--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 settings_at_start = trust::Settings::load(); + let opts = pf_presenter::SessionOpts { + window_title: window_label.map_or_else( + || "Punktfunk".to_string(), + |label| format!("Punktfunk · {label}"), + ), + fullscreen: fullscreen_mode(), + window_pos: window_pos(), + print_stats: settings_at_start.show_stats || arg_flag("--stats"), + json_status, + on_connected: Some(Box::new(|fingerprint: [u8; 32]| { + trust::touch_last_used(&trust::hex(&fingerprint)); + })), + overlay: Some(Box::new(overlay)), + }; + + let result = + pf_presenter::run_browse(opts, |action, gamepad, native, force_software, vulkan| { + match action { + OverlayAction::Launch { + addr, + port, + fp_hex, + launch, + title, + } => { + let Some(pin) = trust::parse_hex32(&fp_hex) else { + // The console only offers Connect on paired rows; a pinless + // launch is a logic slip, never a silent TOFU. + tracing::warn!(%addr, "launch without a stored pin — refusing"); + return ActionOutcome::Handled; + }; + tracing::info!(%addr, %title, launch = launch.as_deref().unwrap_or("desktop"), + "launching from the console"); + // Settings re-load per launch: the console's own settings screen + // may have changed them since the last stream. + let settings = trust::Settings::load(); + ActionOutcome::Start(Box::new(session_params( + &settings, + addr, + port, + pin, + identity.clone(), + launch, + gamepad, + native, + force_software, + vulkan, + ))) + } + OverlayAction::CancelConnect => ActionOutcome::Handled, // run-loop-side + OverlayAction::Quit => ActionOutcome::Quit, + } + }); + + service.stop(); + + 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!("console: {e:#}"); + crate::session_main::EXIT_PRESENTER_FAILED + } + } +} + +/// The machine's name — what the host lists this client as after pairing. +fn device_name() -> String { + #[cfg(target_os = "linux")] + if let Ok(s) = std::fs::read_to_string("/etc/hostname") { + let s = s.trim(); + if !s.is_empty() { + return s.to_string(); + } + } + std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "This device".into()) +} + +fn host_display_name(name: &str, addr: &str) -> String { + if name.trim().is_empty() { + addr.to_string() + } else { + name.to_string() + } +} + +fn fake_host_row() -> HostRow { + HostRow { + key: "fake".into(), + name: "Demo Host".into(), + addr: "127.0.0.1".into(), + port: 9777, + fp_hex: String::new(), + paired: true, + saved: true, + online: true, + mgmt_port: library::DEFAULT_MGMT_PORT, + can_wake: false, + last_used: None, + } +} + +/// The background service: owns discovery, probing, waking, pairing and persistence. +struct Service { + stop: Arc, + thread: Option>, +} + +impl Service { + fn start( + console: ConsoleShared, + library_model: LibraryShared, + bus: pf_console_ui::ConsoleBus, + identity: (String, String), + seed: Option, + ) -> Service { + let stop = Arc::new(AtomicBool::new(false)); + let stop_w = stop.clone(); + let thread = std::thread::Builder::new() + .name("punktfunk-console".into()) + .spawn(move || { + ServiceState { + console, + library: library_model, + bus, + identity, + seed, + discovered: HashMap::new(), + probed: Arc::new(Mutex::new(HashMap::new())), + probe_inflight: Arc::new(AtomicBool::new(false)), + last_probe: Instant::now() - Duration::from_secs(60), + wake_cancel: None, + } + .run(stop_w) + }) + .ok(); + Service { stop, thread } + } + + fn stop(mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +struct ServiceState { + console: ConsoleShared, + library: LibraryShared, + bus: pf_console_ui::ConsoleBus, + identity: (String, String), + /// A `--browse` target that isn't in the store yet — kept on the list until the + /// store or discovery covers it. + seed: Option, + discovered: HashMap, + /// Probe results by row key, written by sweep threads. + probed: Arc>>, + probe_inflight: Arc, + last_probe: Instant, + /// Cancels the active wake thread (it owns the model's wake status). + wake_cancel: Option>, +} + +impl ServiceState { + fn run(mut self, stop: Arc) { + let discovery_rx = discovery::browse(); + while !stop.load(Ordering::SeqCst) { + // mDNS churn. + while let Ok(ev) = discovery_rx.try_recv() { + match ev { + discovery::DiscoveryEvent::Resolved(host) => { + self.discovered.insert(host.fullname.clone(), host); + } + discovery::DiscoveryEvent::Removed { fullname } => { + self.discovered.remove(&fullname); + } + } + } + + // Shell commands (plus the binary's own seeded initial fetch). + for cmd in self.bus.drain() { + self.handle(cmd); + } + + // The 10 s reachability sweep — saved hosts that don't advertise (routed / + // multicast-filtered networks) still get honest presence pips. + if self.last_probe.elapsed() >= Duration::from_secs(10) { + self.last_probe = Instant::now(); + self.sweep(); + } + + self.console.set_hosts(self.rows()); + std::thread::sleep(Duration::from_millis(100)); + } + if let Some(c) = &self.wake_cancel { + c.store(true, Ordering::SeqCst); + } + } + + fn handle(&mut self, cmd: ConsoleCmd) { + match cmd { + ConsoleCmd::FetchLibrary { addr, mgmt, fp_hex } => { + spawn_fetch( + self.library.clone(), + addr, + mgmt, + self.identity.clone(), + trust::parse_hex32(&fp_hex), + ); + } + ConsoleCmd::Pair { + addr, + port, + pin, + device_name, + } => { + // Prefer what the list already calls this host (advert or store). + let name = self + .rows() + .into_iter() + .find(|r| r.addr == addr && r.port == port) + .map_or_else(|| addr.clone(), |r| r.name); + self.console.set_pair(PairPhase::Busy); + let console = self.console.clone(); + let identity = self.identity.clone(); + std::thread::Builder::new() + .name("punktfunk-pair".into()) + .spawn(move || { + match trust::pair_with_host(&addr, port, &identity, &pin, &device_name) { + Ok(fp) => { + let fp_hex = trust::hex(&fp); + trust::persist_host(&name, &addr, port, &fp_hex, true); + console.set_pair(PairPhase::Paired { key: fp_hex }); + } + Err(e) => { + let msg = match e { + punktfunk_core::PunktfunkError::Crypto => { + "Wrong PIN — check the host's Pairing page and try again." + .to_string() + } + punktfunk_core::PunktfunkError::Timeout => { + "The host didn't answer. Is it running and reachable?" + .to_string() + } + other => format!("Pairing failed: {other:?}"), + }; + console.set_pair(PairPhase::Failed(msg)); + } + } + }) + .ok(); + } + ConsoleCmd::SaveHost { name, addr, port } => { + let mut known = trust::KnownHosts::load(); + // Manual entries have no fingerprint yet, so `upsert` (fp-keyed) would + // collide two of them — key manual saves by address instead. + if let Some(h) = known + .hosts + .iter_mut() + .find(|h| h.addr == addr && h.port == port) + { + if !name.is_empty() { + h.name = name; + } + } else { + known.hosts.push(trust::KnownHost { + name: if name.is_empty() { addr.clone() } else { name }, + addr, + port, + fp_hex: String::new(), + paired: false, + last_used: None, + mac: Vec::new(), + }); + } + if let Err(e) = known.save() { + tracing::warn!(error = %format!("{e:#}"), "saving known hosts"); + } + self.last_probe = Instant::now() - Duration::from_secs(60); // probe it now + } + ConsoleCmd::Wake { key, then_connect } => { + if let Some(c) = self.wake_cancel.take() { + c.store(true, Ordering::SeqCst); + } + let Some(row) = self.rows().into_iter().find(|r| r.key == key) else { + return; + }; + let known = trust::KnownHosts::load(); + let macs = known + .hosts + .iter() + .find(|h| { + h.fp_hex == row.fp_hex && !row.fp_hex.is_empty() + || (h.addr == row.addr && h.port == row.port) + }) + .map(|h| h.mac.clone()) + .unwrap_or_default(); + if macs.is_empty() { + self.console.set_pair(PairPhase::Idle); // no-op; keep state sane + return; + } + let cancel = Arc::new(AtomicBool::new(false)); + self.wake_cancel = Some(cancel.clone()); + spawn_wake(self.console.clone(), row, macs, then_connect, cancel); + } + ConsoleCmd::CancelWake => { + if let Some(c) = self.wake_cancel.take() { + c.store(true, Ordering::SeqCst); + } + self.console.set_wake(None); + } + ConsoleCmd::Probe => { + self.last_probe = Instant::now() - Duration::from_secs(60); + } + } + } + + /// One parallel reachability pass over every non-advertising row (advertising ones + /// are online by definition). Runs on its own thread; at most one in flight. + fn sweep(&self) { + if self.probe_inflight.swap(true, Ordering::SeqCst) { + return; + } + let targets: Vec<(String, (String, u16))> = self + .rows() + .into_iter() + .filter(|r| !self.advertised(r)) + .map(|r| (r.key.clone(), (r.addr.clone(), r.port))) + .collect(); + let probed = self.probed.clone(); + let inflight = self.probe_inflight.clone(); + std::thread::Builder::new() + .name("punktfunk-probe".into()) + .spawn(move || { + let (keys, addrs): (Vec<_>, Vec<_>) = targets.into_iter().unzip(); + let results = trust::probe_reachable_many(addrs, Duration::from_millis(900)); + let mut map = probed.lock().unwrap(); + for (key, ok) in keys.into_iter().zip(results) { + map.insert(key, ok); + } + inflight.store(false, Ordering::SeqCst); + }) + .ok(); + } + + fn advertised(&self, row: &HostRow) -> bool { + self.discovered.values().any(|d| { + (!row.fp_hex.is_empty() && d.fp_hex == row.fp_hex) + || (d.addr == row.addr && d.port == row.port) + }) + } + + /// The console home's rows: saved hosts (most recent first), then + /// discovered-but-unsaved ones, then a still-uncovered `--browse` seed. + fn rows(&self) -> Vec { + let known = trust::KnownHosts::load(); + let probed = self.probed.lock().unwrap(); + let mut rows: Vec = known + .hosts + .iter() + .map(|h| { + let key = if h.fp_hex.is_empty() { + format!("{}:{}", h.addr, h.port) + } else { + h.fp_hex.clone() + }; + let advert = self.discovered.values().find(|d| { + (!h.fp_hex.is_empty() && d.fp_hex == h.fp_hex) + || (d.addr == h.addr && d.port == h.port) + }); + let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false); + HostRow { + key, + name: host_display_name(&h.name, &h.addr), + addr: h.addr.clone(), + port: h.port, + fp_hex: h.fp_hex.clone(), + paired: h.paired, + saved: true, + online, + mgmt_port: advert + .and_then(|d| d.mgmt_port) + .unwrap_or(library::DEFAULT_MGMT_PORT), + can_wake: !online && !h.mac.is_empty(), + last_used: h.last_used, + } + }) + .collect(); + rows.sort_by(|a, b| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name))); + + let mut extra: Vec = self + .discovered + .values() + .filter(|d| { + !known.hosts.iter().any(|h| { + (!h.fp_hex.is_empty() && h.fp_hex == d.fp_hex) + || (h.addr == d.addr && h.port == d.port) + }) + }) + .map(|d| HostRow { + key: if d.fp_hex.is_empty() { + format!("{}:{}", d.addr, d.port) + } else { + d.fp_hex.clone() + }, + name: host_display_name(&d.name, &d.addr), + addr: d.addr.clone(), + port: d.port, + fp_hex: d.fp_hex.clone(), + paired: false, + saved: false, + online: true, + mgmt_port: d.mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT), + can_wake: false, + last_used: None, + }) + .collect(); + extra.sort_by(|a, b| a.name.cmp(&b.name)); + rows.extend(extra); + + if let Some(seed) = &self.seed { + if !rows + .iter() + .any(|r| r.addr == seed.addr && r.port == seed.port) + { + let mut seed = seed.clone(); + seed.online = probed.get(&seed.key).copied().unwrap_or(false); + rows.push(seed); + } + } + rows + } +} + +/// The wake-and-wait loop (one per wake): re-send the magic packet every 6 s, probe the +/// host once a second, 90 s timeout — the Apple `HostWaker`'s cadence. The thread owns +/// the model's wake status; the shell reads `online`/`timed_out` and acts. +fn spawn_wake( + console: ConsoleShared, + row: HostRow, + macs: Vec, + then_connect: bool, + cancel: Arc, +) { + std::thread::Builder::new() + .name("punktfunk-wake".into()) + .spawn(move || { + let last_ip = row.addr.parse::().ok(); + let started = Instant::now(); + let mut last_packet: Option = None; + loop { + if cancel.load(Ordering::SeqCst) { + console.set_wake(None); + return; + } + let elapsed = started.elapsed(); + let timed_out = elapsed >= Duration::from_secs(90); + if !timed_out && last_packet.is_none_or(|t| t.elapsed() >= Duration::from_secs(6)) { + wol::wake(&macs, last_ip); + last_packet = Some(Instant::now()); + } + let online = trust::probe_reachable_many( + vec![(row.addr.clone(), row.port)], + Duration::from_millis(900), + ) + .first() + .copied() + .unwrap_or(false); + console.set_wake(Some(WakeStatus { + key: row.key.clone(), + name: row.name.clone(), + seconds: elapsed.as_secs() as u32, + timed_out, + online, + then_connect, + })); + if online || timed_out { + // Awake → the shell connects and cancels; timed out → the card + // waits for Try Again / Cancel. Either way this thread is done — + // a retry spawns a fresh one. + return; + } + std::thread::sleep(Duration::from_millis(1000)); + } + }) + .ok(); +} + +/// Fetch the library off the service thread, then stream poster art into the shared +/// model as results land (the renderer drains `push_art` per frame). +fn spawn_fetch( + shared: LibraryShared, + addr: String, + mgmt: u16, + identity: (String, String), + pin: Option<[u8; 32]>, +) { + shared.set_phase(LibraryPhase::Loading); + std::thread::Builder::new() + .name("punktfunk-library".into()) + .spawn(move || { + if let Ok(path) = std::env::var("PUNKTFUNK_FAKE_LIBRARY") { + load_fake(&shared, &path); + return; + } + match library::fetch_games(&addr, mgmt, &identity, pin) { + Ok(games) => { + let base = library::base_url(&addr, mgmt); + let jobs: VecDeque<(String, Vec)> = games + .iter() + .map(|g| (g.id.clone(), g.art.poster_candidates(&base))) + .filter(|(_, candidates)| !candidates.is_empty()) + .collect(); + shared.set_games( + games + .iter() + .map(|g| LibraryGame { + id: g.id.clone(), + title: g.title.clone(), + store: g.store.clone(), + }) + .collect(), + ); + if !jobs.is_empty() { + let rx = library::spawn_art_fetch(base, identity, pin, jobs); + while let Ok((id, bytes)) = rx.recv_blocking() { + shared.push_art(id, bytes); + } + } + } + Err(e) => shared.set_phase(LibraryPhase::Error { + title: "Couldn't load the library".into(), + body: e.to_string(), + can_retry: true, + }), + } + }) + .ok(); +} + +/// Dev hook: entries from a JSON file; portrait paths starting with `/` load from disk. +fn load_fake(shared: &LibraryShared, path: &str) { + let games: Vec = std::fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + for g in &games { + if let Some(p) = g.art.portrait.as_deref().filter(|p| p.starts_with('/')) { + if let Ok(bytes) = std::fs::read(p) { + shared.push_art(g.id.clone(), bytes); + } + } + } + shared.set_games( + games + .iter() + .map(|g| LibraryGame { + id: g.id.clone(), + title: g.title.clone(), + store: g.store.clone(), + }) + .collect(), + ); +} diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index c17a233d..58764b13 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -14,7 +14,7 @@ //! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed. #[cfg(all(any(target_os = "linux", windows), feature = "ui"))] -mod browse; +mod console; #[cfg(any(target_os = "linux", windows))] mod session_main { @@ -244,9 +244,12 @@ mod session_main { } } - if let Some(target) = arg_value("--browse") { + if arg_flag("--browse") { + // Bare `--browse` opens the console home (hosts, pairing, settings); + // `--browse host[:port]` opens straight into that host's library. + let target = arg_value("--browse"); #[cfg(feature = "ui")] - return crate::browse::run(&target); + return crate::console::run(target.as_deref()); #[cfg(not(feature = "ui"))] { let _ = target; @@ -260,12 +263,13 @@ 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] [--json-status]\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\ - desktop client or `punktfunk-client --pair --connect host[:port]` —\n\ - this binary never connects to a host it has no pinned fingerprint for." + Streams from a paired punktfunk host in a Vulkan window. --browse opens the\n\ + gamepad console instead: bare --browse is the host list (discovery, PIN\n\ + pairing, settings, wake-on-LAN); with a target it opens that host's game\n\ + library. --connect never dials a host it has no pinned fingerprint for —\n\ + pair in the console or via `punktfunk-client --pair --connect …`." ); return EXIT_CONNECT_FAILED; }; diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 82d9930d..f4544e6f 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -360,8 +360,9 @@ 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 through this one - // struct; the session binary only reads them and must never call `save`. + // shell's on Windows. The desktop shells AND the session binary's console + // settings screen write it (load-modify-save per change — Gaming Mode has no + // other editor); a plain `--connect` stream only ever reads. #[cfg(windows)] return Ok(config_dir()?.join("client-windows-settings.json")); #[cfg(not(windows))] diff --git a/crates/pf-console-ui/Cargo.toml b/crates/pf-console-ui/Cargo.toml index 17bd05bb..70a72c69 100644 --- a/crates/pf-console-ui/Cargo.toml +++ b/crates/pf-console-ui/Cargo.toml @@ -23,6 +23,8 @@ ash = { version = "0.38", features = ["loaded"] } anyhow = "1" tracing = "0.1" +# `config::GamepadPref` keys the button-glyph style (PlayStation shapes vs ABXY). +punktfunk-core = { path = "../punktfunk-core" } # Linux links the system SDL3; Windows builds it from source (same choice as the rest # of the workspace's Windows SDL consumers). diff --git a/crates/pf-console-ui/assets/fonts/Geist-Bold.otf b/crates/pf-console-ui/assets/fonts/Geist-Bold.otf new file mode 100644 index 00000000..6ab5615f Binary files /dev/null and b/crates/pf-console-ui/assets/fonts/Geist-Bold.otf differ diff --git a/crates/pf-console-ui/assets/fonts/Geist-Medium.otf b/crates/pf-console-ui/assets/fonts/Geist-Medium.otf new file mode 100644 index 00000000..99fb7c28 Binary files /dev/null and b/crates/pf-console-ui/assets/fonts/Geist-Medium.otf differ diff --git a/crates/pf-console-ui/assets/fonts/Geist-OFL.txt b/crates/pf-console-ui/assets/fonts/Geist-OFL.txt new file mode 100644 index 00000000..04e95fc5 --- /dev/null +++ b/crates/pf-console-ui/assets/fonts/Geist-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/crates/pf-console-ui/assets/fonts/Geist-Regular.otf b/crates/pf-console-ui/assets/fonts/Geist-Regular.otf new file mode 100644 index 00000000..82878335 Binary files /dev/null and b/crates/pf-console-ui/assets/fonts/Geist-Regular.otf differ diff --git a/crates/pf-console-ui/assets/fonts/Geist-SemiBold.otf b/crates/pf-console-ui/assets/fonts/Geist-SemiBold.otf new file mode 100644 index 00000000..277a521a Binary files /dev/null and b/crates/pf-console-ui/assets/fonts/Geist-SemiBold.otf differ diff --git a/crates/pf-console-ui/src/anim.rs b/crates/pf-console-ui/src/anim.rs new file mode 100644 index 00000000..12d1e662 --- /dev/null +++ b/crates/pf-console-ui/src/anim.rs @@ -0,0 +1,124 @@ +//! The console shell's motion vocabulary. Two kinds of movement, deliberately kept +//! apart: **springs** (`Spring`, wrapping `library::spring_advance`) for anything the +//! user pushes around — cursors, trays, recoil — where velocity must carry across +//! retargets; and **timed progressions** (`Progress` + the easing functions) for +//! fire-and-forget choreography — screen entrances/exits, fades — where a deterministic +//! duration matters more than momentum. + +use crate::library::spring_advance; + +/// Ease-out cubic — fast start, gentle landing. The screen-transition curve (the WinUI +/// shell's entrance tween uses the same shape). +pub(crate) fn ease_out_cubic(t: f64) -> f64 { + let u = 1.0 - t.clamp(0.0, 1.0); + 1.0 - u * u * u +} + +/// Exponential approach: move `current` toward `target` with time-constant `tau` +/// seconds. Frame-rate independent, never overshoots — the focus-scale smoothing +/// (SwiftUI's `.smooth(0.18)` reads the same). +pub(crate) fn approach(current: f64, target: f64, dt: f64, tau: f64) -> f64 { + current + (target - current) * (1.0 - (-dt / tau).exp()) +} + +/// A damped spring with persistent velocity. `k`/`c` choose the feel; see the pairs in +/// [`crate::library`] (cursor chase, boundary bump) and [`TRAY_K`]/[`TRAY_C`] below. +#[derive(Clone, Copy)] +pub(crate) struct Spring { + pub pos: f64, + pub vel: f64, +} + +impl Spring { + pub(crate) fn rest(pos: f64) -> Spring { + Spring { pos, vel: 0.0 } + } + + pub(crate) fn step(&mut self, target: f64, k: f64, c: f64, dt: f64) { + (self.pos, self.vel) = spring_advance(self.pos, self.vel, target, k, c, dt); + } + + /// Snap onto `target` once the motion is imperceptible (stops per-frame damage). + pub(crate) fn settle(&mut self, target: f64, eps_pos: f64, eps_vel: f64) { + if (target - self.pos).abs() < eps_pos && self.vel.abs() < eps_vel { + self.pos = target; + self.vel = 0.0; + } + } +} + +/// The keyboard tray's slide (SwiftUI `.spring(response: 0.32, dampingFraction: 0.86)`: +/// k = (2π/response)², c = 2·ζ·√k). +pub(crate) const TRAY_K: f64 = 385.0; +pub(crate) const TRAY_C: f64 = 33.7; + +/// A clamped 0→1 timer for fire-and-forget choreography. `advance` returns the RAW +/// progress — callers apply their easing so one Progress can drive several curves. +#[derive(Clone, Copy)] +pub(crate) struct Progress { + t: f64, + duration: f64, +} + +impl Progress { + pub(crate) fn new(duration: f64) -> Progress { + Progress { t: 0.0, duration } + } + + pub(crate) fn advance(&mut self, dt: f64) -> f64 { + self.t = (self.t + dt / self.duration).min(1.0); + self.t + } + + pub(crate) fn value(&self) -> f64 { + self.t + } + + pub(crate) fn done(&self) -> bool { + self.t >= 1.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ease_out_cubic_shape() { + assert_eq!(ease_out_cubic(0.0), 0.0); + assert_eq!(ease_out_cubic(1.0), 1.0); + assert!(ease_out_cubic(0.5) > 0.5, "front-loaded"); + assert_eq!(ease_out_cubic(2.0), 1.0, "clamped"); + } + + #[test] + fn approach_converges_and_never_overshoots() { + let mut v = 0.0; + for _ in 0..120 { + v = approach(v, 1.0, 1.0 / 60.0, 0.06); + assert!(v <= 1.0); + } + assert!((v - 1.0).abs() < 1e-6); + } + + #[test] + fn progress_completes_on_time() { + let mut p = Progress::new(0.3); + let mut steps = 0; + while !p.done() { + p.advance(1.0 / 60.0); + steps += 1; + } + assert!((17..=19).contains(&steps), "{steps}"); // 0.3 s at 60 Hz + } + + #[test] + fn spring_settles() { + let mut s = Spring::rest(0.0); + for _ in 0..240 { + s.step(1.0, 200.0, 24.0, 1.0 / 60.0); + s.settle(1.0, 0.001, 0.01); + } + assert_eq!((s.pos, s.vel), (1.0, 0.0)); + } +} diff --git a/crates/pf-console-ui/src/glyphs.rs b/crates/pf-console-ui/src/glyphs.rs new file mode 100644 index 00000000..809076f6 --- /dev/null +++ b/crates/pf-console-ui/src/glyphs.rs @@ -0,0 +1,354 @@ +//! Controller button glyphs and the hint bar — the "controls legend" pill every console +//! screen pins bottom-leading (the Apple client resolves real SF glyphs per pad via +//! `sfSymbolsName`; here the shapes are drawn). The style follows the ACTIVE pad: +//! PlayStation controllers read ✕/○/□/△, everything else reads ABXY letters, and with +//! no pad at all the legend swaps to keyboard keycaps — the console stays fully +//! drivable either way. + +use crate::theme::{white, Fonts, W}; +use punktfunk_core::config::GamepadPref; +use skia_safe::{Canvas, Color4f, Paint, Path, Point, RRect, Rect}; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum GlyphStyle { + /// ABXY letter badges (Xbox / Steam Deck / generic). + Letters, + /// PlayStation face shapes (DualSense / DualShock 4). + Shapes, + /// No controller — keyboard keycaps. + Keyboard, +} + +impl GlyphStyle { + pub(crate) fn from_pref(pref: Option) -> GlyphStyle { + match pref { + Some(GamepadPref::DualSense | GamepadPref::DualShock4) => GlyphStyle::Shapes, + Some(_) => GlyphStyle::Letters, + None => GlyphStyle::Keyboard, + } + } +} + +/// What a hint's glyph depicts. `Key` renders a literal keycap chip in any style (used +/// for keyboard fallbacks and the Deck's "Steam + X" keyboard chord). +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum HintKey { + Confirm, + Back, + Secondary, + Tertiary, + Shoulders, + /// ◀ ▶ — left/right adjusts the focused value. + Adjust, + Key(&'static str), +} + +pub(crate) struct Hint { + pub key: HintKey, + pub label: String, +} + +impl Hint { + pub(crate) fn new(key: HintKey, label: impl Into) -> Hint { + Hint { + key, + label: label.into(), + } + } +} + +const LABEL_SIZE: f64 = 14.0; +const BADGE_D: f64 = 22.0; // face-button badge diameter + +/// The hint bar pill, anchored at its BOTTOM-LEFT corner. Returns the pill's size. +pub(crate) fn hint_bar( + canvas: &Canvas, + fonts: &Fonts, + hints: &[Hint], + style: GlyphStyle, + x: f64, + bottom: f64, + k: f64, +) -> (f64, f64) { + if hints.is_empty() { + return (0.0, 0.0); + } + let pad = 13.0 * k; + let gap_hint = 18.0 * k; + let gap_glyph = 7.0 * k; + let widths: Vec<(f64, f64)> = hints + .iter() + .map(|h| { + ( + glyph_width(fonts, h.key, style, k), + fonts.measure(&h.label, W::SemiBold, LABEL_SIZE * k) as f64, + ) + }) + .collect(); + let content_w: f64 = widths.iter().map(|(g, l)| g + gap_glyph + l).sum::() + + gap_hint * (hints.len() - 1) as f64; + let h = BADGE_D * k + 2.0 * pad; + let w = content_w + 2.0 * pad; + let rect = Rect::from_xywh((x) as f32, (bottom - h) as f32, w as f32, h as f32); + canvas.draw_rrect( + RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32), + &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.30), None), + ); + canvas.draw_rrect( + RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32), + &Paint::new(white(0.06), None), + ); + let mut sp = Paint::new(white(0.12), None); + sp.set_style(skia_safe::PaintStyle::Stroke); + sp.set_stroke_width(1.0); + sp.set_anti_alias(true); + canvas.draw_rrect( + RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32), + &sp, + ); + + let cy = bottom - h / 2.0; + let mut pen = x + pad; + for (hint, (gw, lw)) in hints.iter().zip(&widths) { + draw_glyph(canvas, fonts, hint.key, style, pen, cy, k); + pen += gw + gap_glyph; + // Baseline centered on the badge (cap height ≈ 0.72 em for Geist). + fonts.draw( + canvas, + &hint.label, + pen, + cy + LABEL_SIZE * k * 0.36, + W::SemiBold, + LABEL_SIZE * k, + white(0.85), + ); + pen += lw + gap_hint; + } + (w, h) +} + +fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> f64 { + match resolved(key, style) { + Resolved::Badge(_) | Resolved::Adjust => BADGE_D * k, + Resolved::Shoulders => 2.0 * shoulder_w(fonts, k) + 3.0 * k, + Resolved::Key(text) => keycap_w(fonts, text, k), + } +} + +fn shoulder_w(fonts: &Fonts, k: f64) -> f64 { + fonts.measure("L1", W::SemiBold, 10.0 * k) as f64 + 10.0 * k +} + +fn keycap_w(fonts: &Fonts, text: &str, k: f64) -> f64 { + fonts.measure(text, W::SemiBold, 11.0 * k) as f64 + 14.0 * k +} + +/// A hint key resolved against the glyph style. +enum Resolved { + /// A face-button badge: the letter (Letters) or shape index (Shapes). + Badge(Face), + Shoulders, + Adjust, + Key(&'static str), +} + +#[derive(Clone, Copy)] +enum Face { + A, + B, + X, + Y, +} + +fn resolved(key: HintKey, style: GlyphStyle) -> Resolved { + if style == GlyphStyle::Keyboard { + return match key { + HintKey::Confirm => Resolved::Key("Enter"), + HintKey::Back => Resolved::Key("Esc"), + HintKey::Secondary => Resolved::Key("Y"), + HintKey::Tertiary => Resolved::Key("X"), + HintKey::Shoulders => Resolved::Key("PgUp/PgDn"), + HintKey::Adjust => Resolved::Adjust, + HintKey::Key(t) => Resolved::Key(t), + }; + } + match key { + HintKey::Confirm => Resolved::Badge(Face::A), + HintKey::Back => Resolved::Badge(Face::B), + HintKey::Tertiary => Resolved::Badge(Face::X), + HintKey::Secondary => Resolved::Badge(Face::Y), + HintKey::Shoulders => Resolved::Shoulders, + HintKey::Adjust => Resolved::Adjust, + HintKey::Key(t) => Resolved::Key(t), + } +} + +/// Draw one glyph with its LEFT edge at `x`, vertically centered on `cy`. +fn draw_glyph( + canvas: &Canvas, + fonts: &Fonts, + key: HintKey, + style: GlyphStyle, + x: f64, + cy: f64, + k: f64, +) { + match resolved(key, style) { + Resolved::Badge(face) => { + let r = BADGE_D * k / 2.0; + let center = Point::new((x + r) as f32, cy as f32); + canvas.draw_circle(center, r as f32, &Paint::new(white(0.10), None)); + let mut ring = Paint::new(white(0.32), None); + ring.set_style(skia_safe::PaintStyle::Stroke); + ring.set_stroke_width((1.2 * k) as f32); + ring.set_anti_alias(true); + canvas.draw_circle(center, r as f32, &ring); + if style == GlyphStyle::Shapes { + draw_ps_shape(canvas, face, center, (4.6 * k) as f32, (1.7 * k) as f32); + } else { + let letter = match face { + Face::A => "A", + Face::B => "B", + Face::X => "X", + Face::Y => "Y", + }; + let size = 12.0 * k; + let w = fonts.measure(letter, W::SemiBold, size) as f64; + fonts.draw( + canvas, + letter, + x + r - w / 2.0, + cy + size * 0.36, + W::SemiBold, + size, + white(0.92), + ); + } + } + Resolved::Shoulders => { + let mut pen = x; + for label in ["L1", "R1"] { + let w = shoulder_w(fonts, k); + let h = 15.0 * k; + let rect = Rect::from_xywh(pen as f32, (cy - h / 2.0) as f32, w as f32, h as f32); + canvas.draw_rrect( + RRect::new_rect_xy(rect, (4.0 * k) as f32, (4.0 * k) as f32), + &Paint::new(white(0.10), None), + ); + let size = 10.0 * k; + let tw = fonts.measure(label, W::SemiBold, size) as f64; + fonts.draw( + canvas, + label, + pen + (w - tw) / 2.0, + cy + size * 0.36, + W::SemiBold, + size, + white(0.92), + ); + pen += w + 3.0 * k; + } + } + Resolved::Adjust => { + // ◀ ▶ — two small solid triangles. + let r = BADGE_D * k / 2.0; + let (cx, cyf) = ((x + r) as f32, cy as f32); + let (tw, th) = ((4.5 * k) as f32, (5.5 * k) as f32); + let gap = (2.6 * k) as f32; + let paint = Paint::new(white(0.85), None); + let mut left = Path::new(); + left.move_to((cx - gap, cyf - th)); + left.line_to((cx - gap - tw, cyf)); + left.line_to((cx - gap, cyf + th)); + left.close(); + canvas.draw_path(&left, &paint); + let mut right = Path::new(); + right.move_to((cx + gap, cyf - th)); + right.line_to((cx + gap + tw, cyf)); + right.line_to((cx + gap, cyf + th)); + right.close(); + canvas.draw_path(&right, &paint); + } + Resolved::Key(text) => { + let w = keycap_w(fonts, text, k); + let h = 18.0 * k; + let rect = Rect::from_xywh(x as f32, (cy - h / 2.0) as f32, w as f32, h as f32); + canvas.draw_rrect( + RRect::new_rect_xy(rect, (5.0 * k) as f32, (5.0 * k) as f32), + &Paint::new(white(0.10), None), + ); + let mut ring = Paint::new(white(0.28), None); + ring.set_style(skia_safe::PaintStyle::Stroke); + ring.set_stroke_width(1.0); + ring.set_anti_alias(true); + canvas.draw_rrect( + RRect::new_rect_xy(rect, (5.0 * k) as f32, (5.0 * k) as f32), + &ring, + ); + let size = 11.0 * k; + let tw = fonts.measure(text, W::SemiBold, size) as f64; + fonts.draw( + canvas, + text, + x + (w - tw) / 2.0, + cy + size * 0.36, + W::SemiBold, + size, + white(0.92), + ); + } + } +} + +/// The PlayStation face shapes, stroked inside the badge: Confirm=✕, Back=○, X-position +/// =□, Y-position=△ (the DualSense's physical layout). +fn draw_ps_shape(canvas: &Canvas, face: Face, center: Point, r: f32, stroke: f32) { + let mut p = Paint::new(white(0.92), None); + p.set_style(skia_safe::PaintStyle::Stroke); + p.set_stroke_width(stroke); + p.set_stroke_cap(skia_safe::PaintCap::Round); + p.set_anti_alias(true); + let (cx, cy) = (center.x, center.y); + match face { + Face::A => { + // ✕ + canvas.draw_line((cx - r, cy - r), (cx + r, cy + r), &p); + canvas.draw_line((cx - r, cy + r), (cx + r, cy - r), &p); + } + Face::B => { + // ○ + canvas.draw_circle(center, r * 1.1, &p); + } + Face::X => { + // □ + canvas.draw_rect(Rect::from_xywh(cx - r, cy - r, 2.0 * r, 2.0 * r), &p); + } + Face::Y => { + // △ + let mut tri = Path::new(); + tri.move_to((cx, cy - r * 1.2)); + tri.line_to((cx + r * 1.15, cy + r * 0.85)); + tri.line_to((cx - r * 1.15, cy + r * 0.85)); + tri.close(); + canvas.draw_path(&tri, &p); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn style_follows_pad_kind() { + assert_eq!( + GlyphStyle::from_pref(Some(GamepadPref::DualSense)), + GlyphStyle::Shapes + ); + assert_eq!( + GlyphStyle::from_pref(Some(GamepadPref::SteamDeck)), + GlyphStyle::Letters + ); + assert_eq!(GlyphStyle::from_pref(None), GlyphStyle::Keyboard); + } +} diff --git a/crates/pf-console-ui/src/lib.rs b/crates/pf-console-ui/src/lib.rs index e13e0659..f6c684ac 100644 --- a/crates/pf-console-ui/src/lib.rs +++ b/crates/pf-console-ui/src/lib.rs @@ -1,21 +1,39 @@ //! The Skia console UI (punktfunk-planning `linux-client-rearchitecture.md` §6): an -//! [`Overlay`] implementation rendering on the PRESENTER's Vulkan device into offscreen -//! RGBA images the presenter composites as one premultiplied quad. Skia never touches -//! the swapchain, and nothing here runs while the overlay has nothing to show — the -//! §6.1 invariants live or die in this crate. +//! [`Overlay`](pf_presenter::overlay::Overlay) implementation rendering on the +//! PRESENTER's Vulkan device into offscreen RGBA images the presenter composites as one +//! premultiplied quad. Skia never touches the swapchain, and nothing here runs while +//! the overlay has nothing to show — the §6.1 invariants live or die in this crate. //! -//! Milestone 1 (this file): the stats OSD panel + the capture-hint pill — small on -//! purpose, it proves the whole shared-device pipeline. The gamepad library moves in -//! next. +//! The console is a full couch shell now — home (host carousel), the game library +//! coverflow, settings, add-host, and PIN pairing, with screen transitions, per-pad +//! button glyphs, and a controller keyboard (suppressed on Steam Deck, where Steam's +//! own keyboard types through SDL text input) — plus the in-stream chrome: stats OSD, +//! capture hint, start banner. +#[cfg(any(target_os = "linux", windows))] +mod anim; +#[cfg(any(target_os = "linux", windows))] +mod glyphs; #[cfg(any(target_os = "linux", windows))] pub mod library; #[cfg(any(target_os = "linux", windows))] -mod library_ui; +pub mod model; +#[cfg(any(target_os = "linux", windows))] +mod screens; +#[cfg(any(target_os = "linux", windows))] +mod shell; #[cfg(any(target_os = "linux", windows))] mod skia_overlay; +#[cfg(any(target_os = "linux", windows))] +mod theme; +#[cfg(any(target_os = "linux", windows))] +mod widgets; #[cfg(any(target_os = "linux", windows))] pub use library::{LibraryGame, LibraryPhase, LibraryShared}; #[cfg(any(target_os = "linux", windows))] -pub use skia_overlay::SkiaOverlay; +pub use model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus}; +#[cfg(any(target_os = "linux", windows))] +pub use shell::ConsoleOptions; +#[cfg(any(target_os = "linux", windows))] +pub use skia_overlay::{ConsoleEntry, ConsoleHandles, SkiaOverlay}; diff --git a/crates/pf-console-ui/src/library.rs b/crates/pf-console-ui/src/library.rs index 655d68a8..d74fec31 100644 --- a/crates/pf-console-ui/src/library.rs +++ b/crates/pf-console-ui/src/library.rs @@ -291,8 +291,6 @@ pub fn mesh_sksl() -> String { #[derive(Clone, PartialEq)] pub enum LibraryPhase { Loading, - /// Browse target isn't paired — pairing is the plugin's job, render the advice. - PairFirst, Error { title: String, body: String, diff --git a/crates/pf-console-ui/src/library_ui.rs b/crates/pf-console-ui/src/library_ui.rs deleted file mode 100644 index 1e5c9fa6..00000000 --- a/crates/pf-console-ui/src/library_ui.rs +++ /dev/null @@ -1,725 +0,0 @@ -//! The console library's Skia side: navigation state, scene selection, and rendering — -//! the GTK launcher (`ui_gamepad_library.rs`) re-homed onto the presenter surface. The -//! mesh-gradient background runs as an SkSL shader at full rate (the 30 Hz CPU path is -//! gone), the coverflow is `concat_44` perspective with paint order = draw order (the -//! restack hack is gone), and every state renders in-scene (gamescope maps no dialogs). - -use crate::library::{ - card_matrix, initials, mesh_sksl, spring_advance, step_cursor, store_label, LibraryGame, - LibraryPhase, LibraryShared, StepResult, BUMP_C, BUMP_K, BUMP_PX, FOCUS_GAP, JUMP, PERSPECTIVE, - POSTER_H, POSTER_W, RECEDE_DIM, RECEDE_SCALE, ROTATE_DEG, SIDE_SPACING, SPRING_C, SPRING_K, - VISIBLE_RANGE, -}; -use anyhow::{anyhow, Result}; -use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse}; -use pf_presenter::overlay::OverlayAction; -use skia_safe::textlayout::{ - FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle, -}; -use skia_safe::{ - Canvas, Color4f, Data, Font, FontStyle, Image, Paint, Point, RRect, Rect, RuntimeEffect, - Typeface, M44, -}; -use std::collections::{HashMap, VecDeque}; -use std::time::Instant; - -pub(crate) struct LibraryUi { - shared: LibraryShared, - host_label: String, - // Synced snapshot of the shared model (re-pulled when the generation bumps). - generation: u64, - phase: LibraryPhase, - games: Vec, - // Navigation: the integer cursor is the authority; the eased position chases it. - cursor: i32, - anim_pos: f64, - anim_vel: f64, - bump: f64, - bump_vel: f64, - last_frame: Option, - t0: Instant, - /// Decoded posters by game id (decode once; Skia uploads lazily on first draw). - art: HashMap, - /// The animated mesh-gradient background (compiled once; drawn first each frame). - mesh: RuntimeEffect, - /// A launch is in flight — menu input parks, the hint bar says Connecting…. - connecting: bool, - /// A session is on screen — the library doesn't render (stream chrome does). - pub(crate) in_stream: bool, - /// Transient error strip on the carousel (connect failures land here). - status: Option, - actions: VecDeque, -} - -impl LibraryUi { - pub(crate) fn new(shared: LibraryShared, host_label: String) -> Result { - let mesh = RuntimeEffect::make_for_shader(mesh_sksl(), None) - .map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?; - Ok(LibraryUi { - shared, - host_label, - generation: u64::MAX, // force the first sync - phase: LibraryPhase::Loading, - games: Vec::new(), - cursor: 0, - anim_pos: 0.0, - anim_vel: 0.0, - bump: 0.0, - bump_vel: 0.0, - last_frame: None, - t0: Instant::now(), - art: HashMap::new(), - mesh, - connecting: false, - in_stream: false, - status: None, - actions: VecDeque::new(), - }) - } - - /// Pull the shared model when it changed; decode any newly arrived poster bytes. - pub(crate) fn sync(&mut self) { - if self.shared.generation() != self.generation { - let (phase, games, generation) = self.shared.snapshot(); - let fresh_games = self.games.len() != games.len() - || self.games.iter().zip(&games).any(|(a, b)| a.id != b.id); - self.phase = phase; - self.games = games; - self.generation = generation; - if fresh_games { - // Fresh library: snap the sprung position onto the (reset) cursor. - self.cursor = 0; - self.anim_pos = 0.0; - self.anim_vel = 0.0; - self.bump = 0.0; - self.bump_vel = 0.0; - } - self.cursor = self.cursor.clamp(0, (self.games.len() as i32 - 1).max(0)); - } - for (id, bytes) in self.shared.drain_art() { - match Image::from_encoded(Data::new_copy(&bytes)) { - Some(img) => { - self.art.insert(id, img); - } - None => tracing::debug!(%id, "undecodable poster"), - } - } - } - - /// Menu-mode navigation (gamepad; the keyboard fallback funnels in here too). - pub(crate) fn menu(&mut self, ev: MenuEvent) -> Option { - if self.connecting { - return None; // a connect is in flight — input is parked - } - match &self.phase { - LibraryPhase::Ready => match ev { - MenuEvent::Move(MenuDir::Left) => self.step(-1, false), - MenuEvent::Move(MenuDir::Right) => self.step(1, false), - MenuEvent::JumpBack => self.step(-JUMP, true), - MenuEvent::JumpForward => self.step(JUMP, true), - MenuEvent::Confirm => { - let g = self.games.get(self.cursor as usize)?; - self.actions.push_back(OverlayAction::Launch { - id: g.id.clone(), - title: g.title.clone(), - }); - self.status = None; - self.connecting = true; - Some(MenuPulse::Confirm) - } - MenuEvent::Back => { - self.actions.push_back(OverlayAction::Quit); - None - } - MenuEvent::Move(_) | MenuEvent::Secondary | MenuEvent::Tertiary => None, - }, - LibraryPhase::Error { can_retry, .. } => match ev { - MenuEvent::Confirm if *can_retry => { - self.phase = LibraryPhase::Loading; // local; the fetch re-syncs it - self.actions.push_back(OverlayAction::Retry); - Some(MenuPulse::Confirm) - } - MenuEvent::Back => { - self.actions.push_back(OverlayAction::Quit); - None - } - _ => None, - }, - LibraryPhase::Loading | LibraryPhase::Empty | LibraryPhase::PairFirst => { - if ev == MenuEvent::Back { - self.actions.push_back(OverlayAction::Quit); - } - None - } - } - } - - /// Keyboard fallback (arrows/Enter/Esc/PageUp/PageDown) — the launcher is fully - /// drivable with no pad. Returns true when consumed. - pub(crate) fn key(&mut self, sc: sdl3::keyboard::Scancode, repeat: bool) -> bool { - use sdl3::keyboard::Scancode as S; - let ev = match sc { - S::Left => MenuEvent::Move(MenuDir::Left), - S::Right => MenuEvent::Move(MenuDir::Right), - S::Up => MenuEvent::Move(MenuDir::Up), - S::Down => MenuEvent::Move(MenuDir::Down), - S::Return | S::KpEnter | S::Space if !repeat => MenuEvent::Confirm, - S::Escape | S::Backspace if !repeat => MenuEvent::Back, - S::PageUp if !repeat => MenuEvent::JumpBack, - S::PageDown if !repeat => MenuEvent::JumpForward, - _ => return false, - }; - self.menu(ev); // no pad to pulse - true - } - - pub(crate) fn take_action(&mut self) -> Option { - self.actions.pop_front() - } - - pub(crate) fn set_connecting(&mut self, on: bool) { - self.connecting = on; - if on { - self.status = None; - } - } - - /// A launch that didn't stick (connect failed / session ended with a reason): - /// carousel-visible errors land on the transient strip, anything else becomes the - /// error scene (no retry — the library itself is fine). - pub(crate) fn session_error(&mut self, msg: &str) { - self.connecting = false; - self.in_stream = false; - if self.phase == LibraryPhase::Ready { - self.status = Some(format!("Couldn't connect — {msg}")); - } else { - self.phase = LibraryPhase::Error { - title: "Couldn't connect".into(), - body: msg.to_string(), - can_retry: false, - }; - } - } - - fn step(&mut self, delta: i32, clamp: bool) -> Option { - match step_cursor(self.cursor, self.games.len(), delta, clamp) { - StepResult::Moved(to) => { - self.cursor = to; - Some(MenuPulse::Move) - } - StepResult::Boundary => { - // Recoil against the push; the stiff bump spring wobbles it back. - self.bump = -BUMP_PX * f64::from(delta.signum()); - self.bump_vel = 0.0; - Some(MenuPulse::Boundary) - } - } - } - - /// Render the whole library scene. Always a full repaint — the aurora animates - /// every frame (that's the point of the GPU port). - pub(crate) fn render(&mut self, canvas: &Canvas, w: u32, h: u32, fonts: &Fonts) { - let (wf, hf) = (w as f64, h as f64); - // Uniform scale off the Deck's 800p design height; fonts and geometry follow. - let k = (hf / 800.0).clamp(0.75, 3.0); - - // Springs (Ready only — other scenes have no strip). - let now = Instant::now(); - let dt = self - .last_frame - .replace(now) - .map_or(1.0 / 60.0, |t| (now - t).as_secs_f64().clamp(0.0, 0.05)); - if self.phase == LibraryPhase::Ready { - let target = f64::from(self.cursor); - (self.anim_pos, self.anim_vel) = - spring_advance(self.anim_pos, self.anim_vel, target, SPRING_K, SPRING_C, dt); - if (target - self.anim_pos).abs() < 0.001 && self.anim_vel.abs() < 0.01 { - self.anim_pos = target; - self.anim_vel = 0.0; - } - (self.bump, self.bump_vel) = - spring_advance(self.bump, self.bump_vel, 0.0, BUMP_K, BUMP_C, dt); - if self.bump.abs() < 0.3 && self.bump_vel.abs() < 4.0 { - self.bump = 0.0; - self.bump_vel = 0.0; - } - } - - self.draw_background(canvas, wf, hf); - - match self.phase.clone() { - LibraryPhase::Ready => self.draw_carousel(canvas, wf, hf, k, fonts), - LibraryPhase::Loading => { - self.draw_spinner(canvas, wf / 2.0, hf / 2.0 - 24.0 * k, 16.0 * k); - fonts.centered( - canvas, - "Loading library…", - 14.0 * k, - DIM_TEXT, - wf / 2.0, - hf / 2.0 + 16.0 * k, - wf * 0.8, - ); - } - LibraryPhase::PairFirst => { - fonts.centered_bold( - canvas, - "Not paired with this host", - 22.0 * k, - WHITE, - wf / 2.0, - hf / 2.0 - 20.0 * k, - wf * 0.8, - ); - fonts.centered( - canvas, - "Pair from the Punktfunk plugin first.", - 14.0 * k, - DIM_TEXT, - wf / 2.0, - hf / 2.0 + 12.0 * k, - wf * 0.8, - ); - } - LibraryPhase::Empty => { - fonts.centered_bold( - canvas, - "No games found", - 22.0 * k, - WHITE, - wf / 2.0, - hf / 2.0 - 20.0 * k, - wf * 0.8, - ); - fonts.centered( - canvas, - "Install Steam titles or add custom entries in the host's web console.", - 14.0 * k, - DIM_TEXT, - wf / 2.0, - hf / 2.0 + 12.0 * k, - wf * 0.8, - ); - } - LibraryPhase::Error { title, body, .. } => { - fonts.centered_bold( - canvas, - &title, - 22.0 * k, - WHITE, - wf / 2.0, - hf / 2.0 - 32.0 * k, - wf * 0.8, - ); - fonts.centered( - canvas, - &body, - 14.0 * k, - DIM_TEXT, - wf / 2.0, - hf / 2.0 + 4.0 * k, - (600.0 * k).min(wf * 0.85), - ); - } - } - - self.draw_chrome(canvas, wf, hf, k, fonts); - } - - fn draw_background(&self, canvas: &Canvas, w: f64, h: f64) { - let t = self.t0.elapsed().as_secs_f64(); - // Uniform layout: float2 u_res, float u_t (declared order, no padding needed). - let uniforms: [f32; 3] = [w as f32, h as f32, t as f32]; - let data = Data::new_copy(bytemuck_bytes(&uniforms)); - match self.mesh.make_shader(data, &[], None) { - Some(shader) => { - let mut paint = Paint::default(); - paint.set_shader(shader); - canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &paint); - } - None => { - canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0)); - } - } - } - - fn draw_carousel(&mut self, canvas: &Canvas, w: f64, h: f64, k: f64, fonts: &Fonts) { - let (card_w, card_h) = (POSTER_W * k, POSTER_H * k); - // The strip's vertical center: the space between the top bar and the detail - // block (the GTK vexpand'ed scroller, approximated). - let cy = h * 0.44; - let pos = self.anim_pos; - let bump = self.bump * k; - - // Paint order = draw order: farthest from the (integer) cursor first, so the - // dense side stacks overlap toward the focus. Stable → the equidistant - // neighbors keep a deterministic order. - let mut order: Vec = (0..self.games.len()).collect(); - order.sort_by_key(|&i| std::cmp::Reverse((i as i32 - self.cursor).abs())); - - for i in order { - let d = i as f64 - pos; - let a = d.abs(); - if a > VISIBLE_RANGE { - continue; - } - let prox = a.min(1.0); - let scale = 1.0 - prox * RECEDE_SCALE; - let angle = -d.clamp(-1.0, 1.0) * ROTATE_DEG; - // Piecewise strip: a full FOCUS_GAP around the focus, then the dense side - // stacks (the classic coverflow shelf). - let offset = if a <= 1.0 { - d * FOCUS_GAP * k - } else { - d.signum() * (FOCUS_GAP + (a - 1.0) * SIDE_SPACING) * k - }; - let cx = w / 2.0 + offset + bump; - let m = card_matrix(cx, cy, angle, scale, card_w, card_h, PERSPECTIVE * k); - - let game = &self.games[i]; - canvas.save(); - canvas.concat_44(&M44::row_major(&m)); - let rect = Rect::from_wh(card_w as f32, card_h as f32); - let rr = RRect::new_rect_xy(rect, 16.0 * k as f32, 16.0 * k as f32); - canvas.clip_rrect(rr, None, true); - match self.art.get(&game.id) { - Some(img) => { - // Cover-fit: center-crop the source to the card's 2:3. - let (iw, ih) = (img.width() as f32, img.height() as f32); - let card_aspect = rect.width() / rect.height(); - let src = if iw / ih > card_aspect { - let sw = ih * card_aspect; - Rect::from_xywh((iw - sw) / 2.0, 0.0, sw, ih) - } else { - let sh = iw / card_aspect; - Rect::from_xywh(0.0, (ih - sh) / 2.0, iw, sh) - }; - canvas.draw_image_rect( - img, - Some((&src, skia_safe::canvas::SrcRectConstraint::Fast)), - rect, - &Paint::default(), - ); - } - None => { - // Solid face, not glass: the side cards OVERLAP (GTK CSS note). - canvas.draw_rect( - rect, - &Paint::new(Color4f::new(0.118, 0.118, 0.145, 1.0), None), - ); - let mono = initials(&game.title); - let font = fonts.sans_bold(38.0 * k); - let tw = font.measure_str(&mono, None).0; - canvas.draw_str( - &mono, - Point::new( - (card_w as f32 - tw) / 2.0, - card_h as f32 / 2.0 + 13.0 * k as f32, - ), - &font, - &Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.45), None), - ); - } - } - // Store badge, top-left. - { - let label = store_label(&game.store); - let font = fonts.sans_bold(11.0 * k); - let tw = font.measure_str(label, None).0; - let (px, py) = (8.0 * k as f32, 8.0 * k as f32); - let (bw, bh) = (tw + 16.0 * k as f32, 20.0 * k as f32); - canvas.draw_rrect( - RRect::new_rect_xy(Rect::from_xywh(px, py, bw, bh), bh / 2.0, bh / 2.0), - &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None), - ); - canvas.draw_str( - label, - Point::new(px + 8.0 * k as f32, py + 14.0 * k as f32), - &font, - &Paint::new(Color4f::new(1.0, 1.0, 1.0, 1.0), None), - ); - } - // The brightness recede: an opaque-black veil, never whole-card alpha. - if prox > 0.0 { - canvas.draw_rect( - rect, - &Paint::new( - Color4f::new(0.0, 0.0, 0.0, (prox * RECEDE_DIM) as f32), - None, - ), - ); - } - canvas.restore(); - } - - // Detail block: focused title + store, centered between strip and hints. - if let Some(g) = self.games.get(self.cursor as usize) { - fonts.centered_bold( - canvas, - &g.title, - 27.0 * k, - WHITE, - w / 2.0, - h - 96.0 * k, - w * 0.8, - ); - fonts.centered( - canvas, - &store_label(&g.store).to_uppercase(), - 12.0 * k, - Color4f::new(1.0, 1.0, 1.0, 0.5), - w / 2.0, - h - 66.0 * k, - w * 0.5, - ); - } - if let Some(status) = &self.status { - fonts.centered( - canvas, - status, - 13.0 * k, - Color4f::new(1.0, 0.576, 0.541, 1.0), // the GTK #ff938a - w / 2.0, - h - 44.0 * k, - w * 0.85, - ); - } - } - - /// Top bar (host + controller chip) and the bottom hint bar — per-scene affordances. - fn draw_chrome(&self, canvas: &Canvas, w: f64, h: f64, k: f64, fonts: &Fonts) { - let font = fonts.sans_bold(18.0 * k); - canvas.draw_str( - &self.host_label, - Point::new(24.0 * k as f32, 32.0 * k as f32), - &font, - &Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.9), None), - ); - if let Some(chip) = &fonts.chip_text { - let cf = fonts.sans(12.0 * k); - let tw = cf.measure_str(chip, None).0; - let (bh, pad) = (24.0 * k as f32, 12.0 * k as f32); - let bx = w as f32 - 24.0 * k as f32 - tw - 2.0 * pad; - canvas.draw_rrect( - RRect::new_rect_xy( - Rect::from_xywh(bx, 18.0 * k as f32, tw + 2.0 * pad, bh), - bh / 2.0, - bh / 2.0, - ), - &Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.08), None), - ); - canvas.draw_str( - chip, - Point::new(bx + pad, 18.0 * k as f32 + 16.0 * k as f32), - &cf, - &Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.7), None), - ); - } - - let hint = if self.connecting { - "Connecting…".to_string() - } else { - match &self.phase { - LibraryPhase::Ready => "A Play B Quit L1 / R1 Jump".to_string(), - LibraryPhase::Error { - can_retry: true, .. - } => "A Retry B Quit".to_string(), - _ => "B Quit".to_string(), - } - }; - fonts.left( - canvas, - &hint, - 13.0 * k, - Color4f::new(1.0, 1.0, 1.0, 0.85), - 24.0 * k, - h - 20.0 * k, - ); - } - - /// The loading spinner: a rotating arc off the aurora clock. - fn draw_spinner(&self, canvas: &Canvas, cx: f64, cy: f64, r: f64) { - let t = self.t0.elapsed().as_secs_f64(); - let start = (t * 300.0) % 360.0; - let mut paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.85), None); - paint.set_style(skia_safe::PaintStyle::Stroke); - paint.set_stroke_width(3.0); - paint.set_anti_alias(true); - canvas.draw_arc( - Rect::from_xywh( - (cx - r) as f32, - (cy - r) as f32, - 2.0 * r as f32, - 2.0 * r as f32, - ), - start as f32, - 270.0, - false, - &paint, - ); - } -} - -const WHITE: Color4f = Color4f::new(1.0, 1.0, 1.0, 1.0); -const DIM_TEXT: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.7); - -fn bytemuck_bytes(v: &[f32; 3]) -> &[u8] { - unsafe { std::slice::from_raw_parts(v.as_ptr().cast::(), 12) } -} - -/// The text toolkit shared by every scene: typefaces + a paragraph-based centered-text -/// helper (shaping + font fallback — poster titles can be CJK; `draw_str` can't). -pub(crate) struct Fonts { - pub sans: Typeface, - pub collection: FontCollection, - /// The controller chip's current text (fed per frame by the overlay). - pub chip_text: Option, -} - -impl Fonts { - fn sans(&self, size: f64) -> Font { - Font::new(self.sans.clone(), size as f32) - } - - fn sans_bold(&self, size: f64) -> Font { - let mut f = Font::new(self.sans.clone(), size as f32); - f.set_embolden(true); - f - } - - fn paragraph( - &self, - text: &str, - size: f64, - color: Color4f, - bold: bool, - align: TextAlign, - max_w: f64, - ) -> skia_safe::textlayout::Paragraph { - let mut style = ParagraphStyle::new(); - style.set_text_align(align); - let mut ts = TextStyle::new(); - ts.set_font_size(size as f32); - ts.set_color(color.to_color()); - ts.set_font_style(if bold { - FontStyle::bold() - } else { - FontStyle::normal() - }); - style.set_text_style(&ts); - let mut b = ParagraphBuilder::new(&style, self.collection.clone()); - b.add_text(text); - let mut p = b.build(); - p.layout(max_w as f32); - p - } - - /// Centered paragraph with `y` as its top edge. - #[allow(clippy::too_many_arguments)] - fn centered( - &self, - canvas: &Canvas, - text: &str, - size: f64, - color: Color4f, - cx: f64, - y: f64, - max_w: f64, - ) { - let p = self.paragraph(text, size, color, false, TextAlign::Center, max_w); - p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32)); - } - - #[allow(clippy::too_many_arguments)] - fn centered_bold( - &self, - canvas: &Canvas, - text: &str, - size: f64, - color: Color4f, - cx: f64, - y: f64, - max_w: f64, - ) { - let p = self.paragraph(text, size, color, true, TextAlign::Center, max_w); - p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32)); - } - - fn left(&self, canvas: &Canvas, text: &str, size: f64, color: Color4f, x: f64, y: f64) { - canvas.draw_str( - text, - Point::new(x as f32, y as f32), - &self.sans(size), - &Paint::new(color, None), - ); - } -} - -/// Resolve the first available family. Generic aliases ("sans-serif", "monospace") -/// resolve through fontconfig on Linux; Windows' DirectWrite-backed FontMgr has no -/// generic aliases, so the list falls through to concrete family names there. -pub(crate) fn match_first_family( - mgr: &skia_safe::FontMgr, - families: &[&str], - style: FontStyle, -) -> Option { - families - .iter() - .find_map(|f| mgr.match_family_style(f, style)) -} - -pub(crate) fn build_fonts() -> Result { - let mgr = skia_safe::FontMgr::new(); - let sans = match_first_family( - &mgr, - &["sans-serif", "Segoe UI", "Arial"], - FontStyle::normal(), - ) - .ok_or_else(|| anyhow!("no sans-serif typeface (fontconfig alias or system family)"))?; - let mut collection = FontCollection::new(); - collection.set_default_font_manager(mgr, None); - Ok(Fonts { - sans, - collection, - chip_text: None, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The generated mesh-gradient SkSL must actually compile (Skia's SkSL frontend runs - /// on the CPU — no GPU needed) — the shape test in `library` can't catch type errors. - #[test] - fn mesh_sksl_compiles() { - RuntimeEffect::make_for_shader(mesh_sksl(), None) - .unwrap_or_else(|e| panic!("mesh-gradient SkSL rejected:\n{e}")); - } - - /// Render the background on a CPU raster surface at a few times and dump PNGs — a visual - /// check of the mesh-gradient look (ignored; run with `--ignored` + PF_MESH_DUMP=). - #[test] - #[ignore] - fn mesh_dump_png() { - let dir = std::env::var("PF_MESH_DUMP").expect("set PF_MESH_DUMP to an output dir"); - let effect = RuntimeEffect::make_for_shader(mesh_sksl(), None).unwrap(); - let (w, h) = (1280i32, 800i32); - for t in [0.0f32, 20.0, 60.0, 300.0] { - let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap(); - let uniforms: [f32; 3] = [w as f32, h as f32, t]; - let data = Data::new_copy(bytemuck_bytes(&uniforms)); - let shader = effect.make_shader(data, &[], None).unwrap(); - let mut paint = Paint::default(); - paint.set_shader(shader); - surface - .canvas() - .draw_rect(Rect::from_wh(w as f32, h as f32), &paint); - let png = surface - .image_snapshot() - .encode(None, skia_safe::EncodedImageFormat::PNG, 100) - .unwrap(); - std::fs::write(format!("{dir}/mesh_t{t}.png"), png.as_bytes()).unwrap(); - } - } -} diff --git a/crates/pf-console-ui/src/model.rs b/crates/pf-console-ui/src/model.rs new file mode 100644 index 00000000..5fbc7691 --- /dev/null +++ b/crates/pf-console-ui/src/model.rs @@ -0,0 +1,202 @@ +//! The console's shared binary↔overlay state and command bus — the widened sibling of +//! [`crate::library::LibraryShared`]. The session binary's service threads (discovery, +//! probing, pairing, waking, persistence) WRITE snapshots in; the shell reads them per +//! frame by generation stamp. The overlay never blocks: anything that touches the +//! network or disk rides a [`ConsoleCmd`] to the binary instead. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +/// One row on the console home carousel — a saved host, a discovered-but-unsaved one, +/// or (client-side) the trailing Add Host tile. Fully resolved by the service thread; +/// the shell renders it verbatim. +#[derive(Clone, Debug, PartialEq)] +pub struct HostRow { + /// Stable identity across refreshes: the pinned fingerprint when known, else + /// `addr:port` — keeps the cursor on "the same host" as snapshots churn. + pub key: String, + pub name: String, + pub addr: String, + pub port: u16, + /// Pinned certificate fingerprint (lowercase hex); empty = not pinned. + pub fp_hex: String, + pub paired: bool, + /// In the known-hosts store (vs. discovered-only). + pub saved: bool, + /// Advertising on mDNS or proven reachable by the probe sweep. + pub online: bool, + /// The management API's port (mDNS TXT or store), for the library fetch. + pub mgmt_port: u16, + /// Offline + a stored MAC → activating wakes first ("Wake & Connect"). + pub can_wake: bool, + /// Last successful connect (UNIX seconds) — the most-recent accent. + pub last_used: Option, +} + +/// The pairing ceremony's observable state (one at a time — the ceremony is modal). +#[derive(Clone, Debug, PartialEq, Default)] +pub enum PairPhase { + #[default] + Idle, + /// The SPAKE2 exchange is running (up to ~90 s on a mistyped-then-fixed PIN). + Busy, + Failed(String), + /// Paired and persisted; `key` addresses the host's refreshed row. + Paired { + key: String, + }, +} + +/// A wake-and-wait in progress (one at a time). The service thread re-sends magic +/// packets and probes; the shell renders the card and acts on `online`. +#[derive(Clone, Debug, PartialEq)] +pub struct WakeStatus { + pub key: String, + pub name: String, + /// Seconds since the wake started (the card's counter). + pub seconds: u32, + pub timed_out: bool, + /// The host answered a probe — the shell launches if the wake wanted a connect. + pub online: bool, + /// Connect once awake (A on an offline host) vs. a bare wake. + pub then_connect: bool, +} + +#[derive(Default)] +struct ConsoleState { + hosts: Vec, + hosts_gen: u64, + pair: PairPhase, + wake: Option, +} + +/// The shared handle. Service threads write; the shell polls per frame (cheap locks, +/// no rendering data inside). +#[derive(Clone, Default)] +pub struct ConsoleShared(Arc>); + +impl ConsoleShared { + pub fn set_hosts(&self, hosts: Vec) { + let mut s = self.0.lock().unwrap(); + if s.hosts != hosts { + s.hosts = hosts; + s.hosts_gen += 1; + } + } + + pub(crate) fn hosts_gen(&self) -> u64 { + self.0.lock().unwrap().hosts_gen + } + + pub(crate) fn hosts_snapshot(&self) -> (Vec, u64) { + let s = self.0.lock().unwrap(); + (s.hosts.clone(), s.hosts_gen) + } + + pub fn set_pair(&self, phase: PairPhase) { + self.0.lock().unwrap().pair = phase; + } + + pub(crate) fn pair(&self) -> PairPhase { + self.0.lock().unwrap().pair.clone() + } + + pub fn set_wake(&self, wake: Option) { + self.0.lock().unwrap().wake = wake; + } + + pub(crate) fn wake(&self) -> Option { + self.0.lock().unwrap().wake.clone() + } +} + +/// Work the shell asks the binary to do. Everything here blocks (network/disk), so it +/// runs on the binary's service thread, never on the render path. +#[derive(Debug, Clone, PartialEq)] +pub enum ConsoleCmd { + /// (Re)fetch a host's game library into the shared library model. + FetchLibrary { + addr: String, + mgmt: u16, + fp_hex: String, + }, + /// Run the SPAKE2 PIN ceremony; on success persist the pin and refresh hosts. + Pair { + addr: String, + port: u16, + pin: String, + device_name: String, + }, + /// Save a manually entered host (unpaired) and refresh the rows. + SaveHost { + name: String, + addr: String, + port: u16, + }, + /// Start the wake-and-wait loop for this saved host. + Wake { key: String, then_connect: bool }, + /// Stop the wake loop (B on the wake card) and clear its status. + CancelWake, + /// Sweep reachability now (the home screen refreshes its presence pips). + Probe, +} + +/// The overlay→binary command queue. A plain deque under the same locking discipline as +/// the shared models — the service thread drains it on a short cadence (it's never +/// latency-critical: every command's effect arrives via a model snapshot anyway). +#[derive(Clone, Default)] +pub struct ConsoleBus(Arc>>); + +impl ConsoleBus { + /// Queue a command. Normally the shell's side; the binary may also seed one (the + /// direct-entry library fetch) — same lane, same handler. + pub fn send(&self, cmd: ConsoleCmd) { + self.0.lock().unwrap().push_back(cmd); + } + + /// Binary side: drain everything queued since the last call. + pub fn drain(&self) -> Vec { + self.0.lock().unwrap().drain(..).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hosts_generation_bumps_only_on_change() { + let shared = ConsoleShared::default(); + let row = HostRow { + key: "aa".into(), + name: "Tower".into(), + addr: "10.0.0.2".into(), + port: 9777, + fp_hex: "aa".into(), + paired: true, + saved: true, + online: false, + mgmt_port: 47990, + can_wake: false, + last_used: None, + }; + shared.set_hosts(vec![row.clone()]); + let g1 = shared.hosts_gen(); + shared.set_hosts(vec![row.clone()]); + assert_eq!(shared.hosts_gen(), g1, "identical snapshot doesn't churn"); + shared.set_hosts(vec![HostRow { + online: true, + ..row + }]); + assert_eq!(shared.hosts_gen(), g1 + 1); + } + + #[test] + fn bus_drains_in_order() { + let bus = ConsoleBus::default(); + bus.send(ConsoleCmd::Probe); + bus.send(ConsoleCmd::CancelWake); + assert_eq!(bus.drain(), vec![ConsoleCmd::Probe, ConsoleCmd::CancelWake]); + assert!(bus.drain().is_empty()); + } +} diff --git a/crates/pf-console-ui/src/screens.rs b/crates/pf-console-ui/src/screens.rs new file mode 100644 index 00000000..8324dc26 --- /dev/null +++ b/crates/pf-console-ui/src/screens.rs @@ -0,0 +1,184 @@ +//! The console's screens and their shared contract. Each screen owns its focus state +//! and rendering; the [`crate::shell::Shell`] owns the stack, the transitions, the +//! chrome, and the overlays — a screen never draws its own background or hint bar, so +//! every screen animates and reads identically. + +pub(crate) mod add_host; +pub(crate) mod home; +pub(crate) mod library; +pub(crate) mod pair; +pub(crate) mod settings; + +use crate::glyphs::Hint; +use crate::library::LibraryShared; +use crate::model::{ConsoleCmd, HostRow}; +use crate::theme::Fonts; +use pf_client_core::gamepad::{MenuEvent, MenuPulse}; +use pf_client_core::{gamepad::PadInfo, trust}; +use skia_safe::{Canvas, Rect}; + +/// What a screen draws over (the shell crossfades between them on push/pop). +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Bg { + /// The living mesh aurora (home, library). + Aurora, + /// The quiet indigo form backdrop (settings, add-host, pair). + Form, +} + +/// Everything a screen may read while handling input or rendering. Settings are +/// mutable — the settings screen edits and persists them in place. +pub(crate) struct Ctx<'a> { + pub hosts: &'a [HostRow], + /// The one live library model slot (the screen on top of the stack owns it). + pub library: &'a LibraryShared, + pub settings: &'a mut trust::Settings, + pub pads: &'a [PadInfo], + /// Steam Deck: never draw our keyboard — Steam's types via SDL text input. + pub deck: bool, + /// The name the HOST stores this client under when pairing (the machine's + /// hostname, resolved by the binary). + pub device_name: &'a str, + /// The shell clock, seconds (spinners, pulses). + pub t: f64, +} + +/// A host a screen wants to start a session on (the shell turns this into an +/// `OverlayAction::Launch` + the connecting overlay). +pub(crate) struct ConnectIntent { + pub addr: String, + pub port: u16, + pub fp_hex: String, + /// Library title id (`None` streams the desktop). + pub launch: Option, + /// What the connecting card says (host or game title). + pub title: String, +} + +pub(crate) enum Nav { + Push(Box), + /// Pop this screen; popping the root quits the console. + Pop, +} + +/// Everything a screen's input handling may ask of the shell, collected per event and +/// applied AFTER the dispatch (no re-entrant stack mutation). +#[derive(Default)] +pub(crate) struct Outbox { + pub nav: Option