feat(linux-client): gamepad library launcher — console-style coverflow (--browse)
ci / rust (push) Successful in 1m54s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m2s
windows-host / package (push) Successful in 6m43s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m12s
ci / bench (push) Successful in 4m47s
apple / swift (push) Successful in 1m9s
android / android (push) Successful in 3m33s
deb / build-publish (push) Successful in 4m36s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
decky / build-publish (push) Successful in 15s
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 4s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 51s
release / apple (push) Successful in 8m30s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m15s
flatpak / build-publish (push) Successful in 4m4s
apple / screenshots (push) Successful in 5m31s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m48s
docker / deploy-docs (push) Successful in 24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m20s
ci / rust (push) Successful in 1m54s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m2s
windows-host / package (push) Successful in 6m43s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m12s
ci / bench (push) Successful in 4m47s
apple / swift (push) Successful in 1m9s
android / android (push) Successful in 3m33s
deb / build-publish (push) Successful in 4m36s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
decky / build-publish (push) Successful in 15s
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 4s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 51s
release / apple (push) Successful in 8m30s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m15s
flatpak / build-publish (push) Successful in 4m4s
apple / screenshots (push) Successful in 5m31s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m48s
docker / deploy-docs (push) Successful in 24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m20s
A controller-driven, chrome-less library launcher for the Steam Deck flow
(the Decky plugin's "Open library on screen" + pinned games, 8470419):
`--browse host[:port]` opens a paired host's game library as a coverflow
over a drifting aurora — A streams the focused title (the id rides the
Hello), session end returns to the launcher, B quits back to Gaming Mode.
`--connect` gains `--launch <id>` for direct-to-game starts; `--mgmt`
overrides the library port. Scope is deliberately library-only: host
selection/settings stay in the touch UI, pairing stays in the plugin (no
dialog can map under gamescope — every state renders in-page).
- gamepad.rs menu mode: the worker holds the active pad open while idle
(WITHOUT the Valve HIDAPI drivers — Deck lizard mode survives) and
translates it through a pure MenuNav state machine: edge-triggered
buttons, held-state snapshot on entry/detach (the escape chord that ends
a stream can't ghost-fire in the menu), 380/160 ms stick/dpad repeat,
menu rumble ticks. Keyboard fallback (arrows/Enter/Esc) drives the same
handler — fully usable with no pad, no host (PUNKTFUNK_FAKE_LIBRARY).
- Coverflow: ±38° corridor-facing tilt under per-card perspective
(gsk rotate_3d), dense overlapping side shelves with paint-order
restacking (gtk::Fixed draws in child order), opaque card faces + a
darkening veil for the recede (translucency would bleed the stack
through). The strip lives in an External-policy ScrolledWindow because
a bare gtk::Fixed measures its TRANSFORMED children and inflates the
page min-width past the window.
- Spring-driven motion: semi-implicit Euler in ≤8 ms substeps (a raw
50 ms frame leaves the stiff recoil spring ringing at ω·dt ≈ 1.2 —
regression-tested), ζ≈0.85 cursor chase + ζ≈0.55 boundary wobble;
velocity carries across retargets so held-repeat scrolling glides.
- Shot scene `gamepad-library` (GTK animations force-disabled in shot mode
— nav transitions froze mid-slide in headless captures); shared poster
fetch extracted to library::spawn_art_fetch.
Verified here: 21 unit tests (MenuNav, cursor stepping, spring
convergence/stability), clippy -D warnings clean, screenshot scene
pixel-checked, --browse smoke runs (fake-library + unpaired) on the
headless session. On-Deck validation pending (virtual-pad input, lizard
mode, rumble via Steam Input, full Decky→browse→stream→launcher loop).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+12
-4
@@ -26,6 +26,10 @@ Built in Rust, it links the shared **`punktfunk-core`** directly (no C ABI) and
|
||||
shows its games (Steam + custom) as a poster grid; click one to launch it in the session.
|
||||
Fetched from the host's management API over mTLS — paired devices are authorized by their
|
||||
certificate, no extra host setup.
|
||||
- **Gamepad library launcher** (`--browse host`) — a console-style, controller-driven coverflow of
|
||||
a paired host's library (drifting aurora backdrop, center-focus posters, button hints): A plays
|
||||
the focused title, B quits, L1/R1 jump. Built for the Steam Deck plugin's "Open library" launch;
|
||||
session end returns to the launcher. Arrow keys/Enter/Esc drive it too (no pad needed).
|
||||
|
||||
## Get it
|
||||
|
||||
@@ -49,24 +53,28 @@ and SDL3 (with hidapi) development packages.
|
||||
```sh
|
||||
# from the repo root
|
||||
cargo run -p punktfunk-client-linux # launch the app
|
||||
cargo run -p punktfunk-client-linux -- --discover # list hosts on the LAN, then exit
|
||||
cargo run -p punktfunk-client-linux -- --connect HOST[:PORT] # skip the host list and connect
|
||||
cargo run -p punktfunk-client-linux -- --browse HOST # the gamepad library launcher
|
||||
```
|
||||
|
||||
The binary is named **`punktfunk-client`**. Handy flags: `--connect host[:port]` (start a session
|
||||
immediately — for scripting and the Steam Deck launcher), `--discover [secs]`, and
|
||||
immediately — for scripting and the Steam Deck launcher) with optional `--launch <id>` (ask the
|
||||
host to launch that library title, id from `--library`), `--browse host[:port]` (the gamepad
|
||||
library launcher; `--mgmt <port>` overrides the management port it fetches from),
|
||||
`--pair <PIN> --connect host[:port]` (run the pairing ceremony headlessly), and
|
||||
`--library host[:mgmt_port]` (print a host's game library headlessly). Force a decoder with
|
||||
`PUNKTFUNK_DECODER=software|vaapi`.
|
||||
`PUNKTFUNK_DECODER=software|vaapi`; `PUNKTFUNK_FAKE_LIBRARY=<file.json>` feeds the launcher
|
||||
canned entries for UI work with no host.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
main.rs · app.rs entry point, GTK application, primary menu, CSS
|
||||
cli.rs CLI paths (--connect, headless --pair, screenshot scenes)
|
||||
cli.rs CLI paths (--connect/--launch, --browse, headless --pair, screenshot scenes)
|
||||
ui_hosts.rs host card grids (saved + discovered) · add-host dialog · banner
|
||||
ui_library.rs game-library poster grid (per-host, launches titles)
|
||||
ui_gamepad_library.rs the --browse gamepad launcher (aurora · coverflow · hint bar)
|
||||
ui_trust.rs TOFU / PIN-pairing / request-access dialogs
|
||||
ui_settings.rs resolution · refresh · decoder · bitrate · compositor · mic
|
||||
ui_stream.rs the stream window (GtkGraphicsOffload present) + input capture
|
||||
|
||||
@@ -34,6 +34,28 @@ const CSS: &str = "
|
||||
xdg_toplevel fullscreen state, so GTK keeps the floating-CSD styling — libadwaita's
|
||||
rounded corners + shadow margin stay visible over the stream. Flatten them outright. */
|
||||
window.pf-chromeless { border-radius: 0; box-shadow: none; }
|
||||
/* The gamepad library launcher (`--browse`, ui_gamepad_library) — always-dark console
|
||||
chrome over the aurora, independent of the desktop theme. */
|
||||
.pf-gl-page { background: black; color: white; }
|
||||
.pf-gl-host { font-size: 1.15em; font-weight: bold; color: rgba(255, 255, 255, 0.9); }
|
||||
.pf-gl-chip { font-size: 0.8em; color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px; padding: 4px 12px; }
|
||||
/* Solid face, not glass: coverflow side cards OVERLAP — a translucent card would bleed
|
||||
the stack through the one on top. */
|
||||
.pf-gl-poster { border-radius: 16px; background: rgb(30, 30, 37);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07); }
|
||||
.pf-gl-dim { background: black; border-radius: 16px; }
|
||||
.pf-gl-detail-title { font-size: 1.7em; font-weight: bold; color: white; }
|
||||
.pf-gl-detail-store { font-size: 0.75em; font-weight: 600; letter-spacing: 2px;
|
||||
color: rgba(255, 255, 255, 0.5); }
|
||||
.pf-gl-glyph { font-size: 0.85em; font-weight: bold; color: white;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px; min-width: 26px; min-height: 26px; padding: 2px 8px; }
|
||||
.pf-gl-hint { color: rgba(255, 255, 255, 0.85); }
|
||||
.pf-gl-status { font-size: 0.85em; color: #ff938a; }
|
||||
.pf-gl-error-title { font-size: 1.4em; font-weight: bold; color: white; }
|
||||
";
|
||||
|
||||
pub struct App {
|
||||
@@ -55,6 +77,9 @@ pub struct App {
|
||||
/// The hosts page handle (banner + per-card connecting spinner), set right after the
|
||||
/// page is built — `None` only during construction.
|
||||
pub hosts: RefCell<Option<Rc<HostsUi>>>,
|
||||
/// The gamepad library launcher — `Some` only under `--browse`, where it replaces the
|
||||
/// hosts page as the root (and session end returns here instead of quitting).
|
||||
pub browse: RefCell<Option<Rc<crate::ui_gamepad_library::LauncherUi>>>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -66,11 +91,17 @@ impl App {
|
||||
self.hosts.borrow().clone()
|
||||
}
|
||||
|
||||
/// Surface a connect failure on the hosts page banner (toast fallback pre-build).
|
||||
pub fn browse_ui(&self) -> Option<Rc<crate::ui_gamepad_library::LauncherUi>> {
|
||||
self.browse.borrow().clone()
|
||||
}
|
||||
|
||||
/// Surface a connect failure: the launcher in browse mode, else the hosts page banner
|
||||
/// (toast fallback pre-build).
|
||||
pub fn connect_error(&self, msg: &str) {
|
||||
match self.hosts_ui() {
|
||||
Some(h) => h.show_error(msg),
|
||||
None => self.toast(msg),
|
||||
match (self.browse_ui(), self.hosts_ui()) {
|
||||
(Some(l), _) => l.show_error(msg),
|
||||
(_, Some(h)) => h.show_error(msg),
|
||||
_ => self.toast(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +143,14 @@ fn build_ui(gtk_app: &adw::Application) {
|
||||
}
|
||||
};
|
||||
load_css();
|
||||
// Screenshot scenes must capture settled frames: kill every GTK/libadwaita animation
|
||||
// (nav-push slides especially — a headless session may starve the frame clock and
|
||||
// leave a transition frozen mid-flight in the capture).
|
||||
if crate::cli::shot_scene().is_some() {
|
||||
if let Some(s) = gtk::Settings::default() {
|
||||
s.set_gtk_enable_animations(false);
|
||||
}
|
||||
}
|
||||
|
||||
let nav = adw::NavigationView::new();
|
||||
let toasts = adw::ToastOverlay::new();
|
||||
@@ -141,8 +180,11 @@ fn build_ui(gtk_app: &adw::Application) {
|
||||
gamepad: crate::gamepad::GamepadService::start(),
|
||||
busy: std::cell::Cell::new(false),
|
||||
fullscreen,
|
||||
// (`--browse` makes cli_connect_request None — browse mode returns to the
|
||||
// launcher on session end instead of quitting.)
|
||||
quit_on_session_end: fullscreen && crate::cli::cli_connect_request().is_some(),
|
||||
hosts: RefCell::new(None),
|
||||
browse: RefCell::new(None),
|
||||
});
|
||||
|
||||
// Re-apply the persisted forwarded-controller pin (stable key; the service matches it
|
||||
@@ -155,6 +197,18 @@ fn build_ui(gtk_app: &adw::Application) {
|
||||
}
|
||||
}
|
||||
|
||||
// Browse mode (`--browse host`): the app IS the gamepad library launcher — it becomes
|
||||
// the ONE root page. No hosts page (whose construction starts the mDNS browse), no
|
||||
// header-menu actions; `Settings::library_enabled` is deliberately ignored (the flag
|
||||
// gates the desktop menu item — asking to browse IS the opt-in here).
|
||||
if let Some((req, paired, mgmt_port)) = crate::cli::cli_browse_request() {
|
||||
let launcher = crate::ui_gamepad_library::open(app.clone(), req, paired, mgmt_port);
|
||||
nav.add(&launcher.page);
|
||||
*app.browse.borrow_mut() = Some(launcher);
|
||||
window.present();
|
||||
return;
|
||||
}
|
||||
|
||||
let hosts_ui = Rc::new(crate::ui_hosts::new(
|
||||
app.settings.clone(),
|
||||
HostsCallbacks {
|
||||
|
||||
+77
-19
@@ -84,7 +84,14 @@ pub fn headless_pair(pin: &str) -> glib::ExitCode {
|
||||
/// already pinned at this address connects silently on its stored pin; an unknown host is
|
||||
/// routed to the PIN ceremony (never a silent TOFU connect — `fp_hex`/`pair_optional` are
|
||||
/// unset, so `initiate_connect`'s manual arm mandates pairing).
|
||||
///
|
||||
/// `--launch <id>` asks the host to launch that library title (store-qualified id from
|
||||
/// `--library`, e.g. `steam:570` — the Decky wrapper's `PF_LAUNCH`); the raw id doubles
|
||||
/// as the stream title (best-effort — no extra fetch just for a prettier label).
|
||||
pub fn cli_connect_request() -> Option<ConnectRequest> {
|
||||
if arg_value("--browse").is_some() {
|
||||
return None; // browse mode owns the session lifecycle (precedence over --connect)
|
||||
}
|
||||
let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?;
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
Some(ConnectRequest {
|
||||
@@ -93,10 +100,43 @@ pub fn cli_connect_request() -> Option<ConnectRequest> {
|
||||
port: port?,
|
||||
fp_hex: None,
|
||||
pair_optional: false,
|
||||
launch: None,
|
||||
launch: arg_value("--launch").map(|id| (id.clone(), id)),
|
||||
})
|
||||
}
|
||||
|
||||
/// `--browse host[:port]` — open the gamepad library launcher for that host instead of
|
||||
/// connecting (the Decky wrapper's `PF_BROWSE`; native port, default 9777). The host must
|
||||
/// already be paired: the stored pin is what lets the launcher fetch the library and
|
||||
/// connect silently — no dialog can run under gamescope, so an unpaired target renders
|
||||
/// the launcher's pair-first scene. Returns the request (name + stored fingerprint from
|
||||
/// the known-hosts store), whether it's paired, and the mgmt port (`--mgmt <port>`, the
|
||||
/// wrapper's `PF_MGMT`; default 47990 — browse mode runs no mDNS to learn it).
|
||||
pub fn cli_browse_request() -> Option<(ConnectRequest, bool, u16)> {
|
||||
let target = arg_value("--browse")?;
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
let port = port.unwrap_or(9777);
|
||||
let known = crate::trust::KnownHosts::load();
|
||||
let k = known
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr && h.port == port);
|
||||
let mgmt = arg_value("--mgmt")
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(crate::library::DEFAULT_MGMT_PORT);
|
||||
Some((
|
||||
ConnectRequest {
|
||||
name: k.map_or_else(|| addr.clone(), |k| k.name.clone()),
|
||||
addr,
|
||||
port,
|
||||
fp_hex: k.map(|k| k.fp_hex.clone()),
|
||||
pair_optional: false,
|
||||
launch: None,
|
||||
},
|
||||
k.is_some_and(|k| k.paired),
|
||||
mgmt,
|
||||
))
|
||||
}
|
||||
|
||||
/// `--library host[:mgmt_port]` — fetch and print the host's game library over the real
|
||||
/// mTLS + pinned-fingerprint client, no GTK window (scripting, and the live-API proof
|
||||
/// that the library HTTP path works against a real host). The pin comes from `--fp HEX`
|
||||
@@ -219,26 +259,17 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
|
||||
// no-art placeholders (monogram tiles), and one solid-color texture standing in
|
||||
// for a loaded poster (the real poster path, minus the network).
|
||||
"library" | "08-library" => {
|
||||
let game = |id: &str, store: &str, title: &str| crate::library::GameEntry {
|
||||
id: id.to_string(),
|
||||
store: store.to_string(),
|
||||
title: title.to_string(),
|
||||
art: crate::library::Artwork::default(),
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
game("steam:1091500", "steam", "Cyberpunk 2077"),
|
||||
game("custom:emu-1", "custom", "RetroArch"),
|
||||
game("heroic:fortnite", "heroic", "Fortnite"),
|
||||
game("gog:witcher3", "gog", "The Witcher 3"),
|
||||
game("lutris:osu", "lutris", "osu!"),
|
||||
];
|
||||
let art = vec![(
|
||||
"steam:570".to_string(),
|
||||
solid_texture(300, 450, 0x35, 0x84, 0xe4),
|
||||
)];
|
||||
let (games, art) = mock_library();
|
||||
crate::ui_library::open_mock(app.clone(), mock_req(), games, art);
|
||||
}
|
||||
// The gamepad launcher (`--browse`) with the same injected entries — cursor sits
|
||||
// at 1 so both recede directions show; aurora + easing render frozen (shot mode).
|
||||
"gamepad-library" | "09-gamepad-library" => {
|
||||
let (games, art) = mock_library();
|
||||
let ui = crate::ui_gamepad_library::open_mock(app.clone(), mock_req(), games, art);
|
||||
app.nav.push(&ui.page);
|
||||
*app.browse.borrow_mut() = Some(ui);
|
||||
}
|
||||
other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"),
|
||||
}
|
||||
|
||||
@@ -268,6 +299,33 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The mock game set shared by the `library` and `gamepad-library` scenes: mixed stores
|
||||
/// exercising the badge set, plus one solid-colour poster texture.
|
||||
fn mock_library() -> (
|
||||
Vec<crate::library::GameEntry>,
|
||||
Vec<(String, gtk::gdk::Texture)>,
|
||||
) {
|
||||
let game = |id: &str, store: &str, title: &str| crate::library::GameEntry {
|
||||
id: id.to_string(),
|
||||
store: store.to_string(),
|
||||
title: title.to_string(),
|
||||
art: crate::library::Artwork::default(),
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
game("steam:1091500", "steam", "Cyberpunk 2077"),
|
||||
game("custom:emu-1", "custom", "RetroArch"),
|
||||
game("heroic:fortnite", "heroic", "Fortnite"),
|
||||
game("gog:witcher3", "gog", "The Witcher 3"),
|
||||
game("lutris:osu", "lutris", "osu!"),
|
||||
];
|
||||
let art = vec![(
|
||||
"steam:570".to_string(),
|
||||
solid_texture(300, 450, 0x35, 0x84, 0xe4),
|
||||
)];
|
||||
(games, art)
|
||||
}
|
||||
|
||||
/// A WxH single-colour RGBA texture — the `library` scene's stand-in for a fetched poster.
|
||||
fn solid_texture(w: i32, h: i32, r: u8, g: u8, b: u8) -> gtk::gdk::Texture {
|
||||
let px = [r, g, b, 0xff].repeat((w * h) as usize);
|
||||
|
||||
+459
-10
@@ -18,6 +18,17 @@
|
||||
//! idle host-list window would kill the Deck's system input. The pad list for Settings is
|
||||
//! built from SDL's ID-based metadata getters, which need no open.
|
||||
//!
|
||||
//! **Menu mode is the one idle exception.** The gamepad library launcher (`--browse`)
|
||||
//! flips [`GamepadService::set_menu_mode`] on for its lifetime: the worker then holds the
|
||||
//! active pad open and translates its buttons/stick into [`MenuEvent`]s (polled off the
|
||||
//! open handle each loop — Apple `GamepadMenuInput` parity: edge-triggered buttons,
|
||||
//! snapshot-on-entry so a button still held from a previous screen or stream can't ghost-
|
||||
//! fire, stick/dpad direction with initial-delay auto-repeat). The Valve HIDAPI drivers
|
||||
//! stay OFF — a plain SDL open of the virtual X360 / evdev pad doesn't touch lizard mode —
|
||||
//! and an attached session always supersedes menu translation (the stream path is
|
||||
//! untouched); detach re-snapshots so the escape chord that ended the session fires
|
||||
//! nothing in the menu.
|
||||
//!
|
||||
//! This thread is also the single consumer of the rumble and HID-output pull planes.
|
||||
|
||||
use punktfunk_core::client::NativeClient;
|
||||
@@ -50,6 +61,169 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir
|
||||
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
|
||||
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple
|
||||
/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift).
|
||||
const MENU_DEADZONE: u16 = 16384;
|
||||
/// A held direction starts auto-repeating after this initial delay…
|
||||
const MENU_REPEAT_DELAY: Duration = Duration::from_millis(380);
|
||||
/// …and then repeats at this cadence until released or changed.
|
||||
const MENU_REPEAT_INTERVAL: Duration = Duration::from_millis(160);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MenuDir {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// One controller action for the launcher UI, translated from the open pad while menu
|
||||
/// mode is on and no session is attached. Buttons are edge-triggered; `Move` debounces
|
||||
/// the stick/dpad and auto-repeats ([`MENU_REPEAT_DELAY`]/[`MENU_REPEAT_INTERVAL`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MenuEvent {
|
||||
Move(MenuDir),
|
||||
/// A — activate the focused item.
|
||||
Confirm,
|
||||
/// B — back / quit.
|
||||
Back,
|
||||
/// Y (Apple "secondary"; unused by the launcher today, kept for parity).
|
||||
Secondary,
|
||||
/// X (Apple "tertiary"; unused).
|
||||
Tertiary,
|
||||
/// L1 — jump back 5.
|
||||
JumpBack,
|
||||
/// R1 — jump forward 5.
|
||||
JumpForward,
|
||||
}
|
||||
|
||||
/// Menu haptic pulses — short rumble ticks on the menu pad (never during a stream).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum MenuPulse {
|
||||
Move,
|
||||
Confirm,
|
||||
Boundary,
|
||||
}
|
||||
|
||||
/// Raw pad state sampled once per worker iteration for menu translation.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct MenuSample {
|
||||
/// a, b, x, y, l1, r1 — the order [`MenuNav::poll`] maps to events.
|
||||
buttons: [bool; 6],
|
||||
/// Left stick, SDL convention (+y = down).
|
||||
lx: i16,
|
||||
ly: i16,
|
||||
/// up, down, left, right.
|
||||
dpad: [bool; 4],
|
||||
}
|
||||
|
||||
/// The pure menu-input state machine (no SDL types — unit-tested below). Port of the
|
||||
/// Swift client's `GamepadMenuInput`: the poll after a [`reset`](Self::reset) adopts the
|
||||
/// currently-held buttons and direction WITHOUT firing, so a press that crossed a screen
|
||||
/// handoff (the B that closed a stream, a held A on mode entry) must be released before
|
||||
/// it can act; buttons fire on the rising edge only.
|
||||
struct MenuNav {
|
||||
/// Adopt the next sample silently (set on mode entry / stream detach / pad change).
|
||||
snapshot_pending: bool,
|
||||
/// Previous button states, [`MenuSample::buttons`] order.
|
||||
was: [bool; 6],
|
||||
dir: Option<MenuDir>,
|
||||
/// When `dir` engaged — start of the initial-repeat delay.
|
||||
dir_since: Instant,
|
||||
last_repeat: Instant,
|
||||
}
|
||||
|
||||
impl MenuNav {
|
||||
fn new() -> MenuNav {
|
||||
MenuNav {
|
||||
snapshot_pending: true,
|
||||
was: [false; 6],
|
||||
dir: None,
|
||||
dir_since: Instant::now(),
|
||||
last_repeat: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Arm the snapshot: the next poll adopts held state without firing.
|
||||
fn reset(&mut self) {
|
||||
self.snapshot_pending = true;
|
||||
self.dir = None;
|
||||
}
|
||||
|
||||
/// Direction from the left stick (dominant axis wins past the deadzone), falling back
|
||||
/// to the discrete dpad. SDL sticks are +y = down.
|
||||
fn resolve_dir(s: &MenuSample) -> Option<MenuDir> {
|
||||
let (ax, ay) = (s.lx.unsigned_abs(), s.ly.unsigned_abs());
|
||||
if ax > MENU_DEADZONE || ay > MENU_DEADZONE {
|
||||
return Some(if ax >= ay {
|
||||
if s.lx > 0 {
|
||||
MenuDir::Right
|
||||
} else {
|
||||
MenuDir::Left
|
||||
}
|
||||
} else if s.ly > 0 {
|
||||
MenuDir::Down
|
||||
} else {
|
||||
MenuDir::Up
|
||||
});
|
||||
}
|
||||
let [up, down, left, right] = s.dpad;
|
||||
if left {
|
||||
Some(MenuDir::Left)
|
||||
} else if right {
|
||||
Some(MenuDir::Right)
|
||||
} else if up {
|
||||
Some(MenuDir::Up)
|
||||
} else if down {
|
||||
Some(MenuDir::Down)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn poll(&mut self, s: &MenuSample, now: Instant, out: &mut Vec<MenuEvent>) {
|
||||
let dir = Self::resolve_dir(s);
|
||||
if self.snapshot_pending {
|
||||
self.snapshot_pending = false;
|
||||
self.was = s.buttons;
|
||||
self.dir = dir;
|
||||
self.dir_since = now;
|
||||
self.last_repeat = now;
|
||||
return;
|
||||
}
|
||||
// buttons order a, b, x, y, l1, r1 → the matching event per index.
|
||||
const EVENTS: [MenuEvent; 6] = [
|
||||
MenuEvent::Confirm,
|
||||
MenuEvent::Back,
|
||||
MenuEvent::Tertiary,
|
||||
MenuEvent::Secondary,
|
||||
MenuEvent::JumpBack,
|
||||
MenuEvent::JumpForward,
|
||||
];
|
||||
for (i, ev) in EVENTS.iter().enumerate() {
|
||||
if s.buttons[i] && !self.was[i] {
|
||||
out.push(*ev);
|
||||
}
|
||||
self.was[i] = s.buttons[i];
|
||||
}
|
||||
if dir != self.dir {
|
||||
self.dir = dir;
|
||||
self.dir_since = now;
|
||||
self.last_repeat = now;
|
||||
if let Some(d) = dir {
|
||||
out.push(MenuEvent::Move(d));
|
||||
}
|
||||
} else if let Some(d) = dir {
|
||||
if now.duration_since(self.dir_since) >= MENU_REPEAT_DELAY
|
||||
&& now.duration_since(self.last_repeat) >= MENU_REPEAT_INTERVAL
|
||||
{
|
||||
self.last_repeat = now;
|
||||
out.push(MenuEvent::Move(d));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PadInfo {
|
||||
pub name: String,
|
||||
@@ -133,6 +307,8 @@ enum Ctl {
|
||||
Attach(Arc<NativeClient>),
|
||||
Detach,
|
||||
Pin(Option<String>),
|
||||
MenuMode(bool),
|
||||
MenuRumble(MenuPulse),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -146,6 +322,9 @@ pub struct GamepadService {
|
||||
/// Fires once when the [`ESCAPE_CHORD`] is held past [`DISCONNECT_HOLD`]; the stream page
|
||||
/// consumes it to end the session (the controller equivalent of Ctrl+Alt+Shift+D).
|
||||
disconnect_rx: async_channel::Receiver<()>,
|
||||
/// Menu-navigation events while menu mode is on and no session is attached; the
|
||||
/// launcher page consumes them.
|
||||
menu_rx: async_channel::Receiver<MenuEvent>,
|
||||
}
|
||||
|
||||
impl GamepadService {
|
||||
@@ -155,11 +334,12 @@ impl GamepadService {
|
||||
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
||||
let (escape_tx, escape_rx) = async_channel::unbounded();
|
||||
let (disconnect_tx, disconnect_rx) = async_channel::unbounded();
|
||||
let (menu_tx, menu_rx) = async_channel::unbounded();
|
||||
let (p, a) = (pads.clone(), active.clone());
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk-gamepad".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = run(&p, &a, &ctl_rx, &escape_tx, &disconnect_tx) {
|
||||
if let Err(e) = run(&p, &a, &ctl_rx, &escape_tx, &disconnect_tx, &menu_tx) {
|
||||
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
||||
}
|
||||
})
|
||||
@@ -172,6 +352,7 @@ impl GamepadService {
|
||||
ctl,
|
||||
escape_rx,
|
||||
disconnect_rx,
|
||||
menu_rx,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +368,25 @@ impl GamepadService {
|
||||
self.disconnect_rx.clone()
|
||||
}
|
||||
|
||||
/// Menu-navigation events ([`MenuEvent`]) — flowing only while menu mode is on and no
|
||||
/// session is attached. A fresh clone per call; the launcher spawns a future on it.
|
||||
pub fn menu_events(&self) -> async_channel::Receiver<MenuEvent> {
|
||||
self.menu_rx.clone()
|
||||
}
|
||||
|
||||
/// Turn menu mode on/off: while on (and no session attached) the worker holds the
|
||||
/// active pad open and translates it into [`MenuEvent`]s. The launcher flips this on
|
||||
/// once for its lifetime — an attached session supersedes translation automatically.
|
||||
pub fn set_menu_mode(&self, on: bool) {
|
||||
let _ = self.ctl.send(Ctl::MenuMode(on));
|
||||
}
|
||||
|
||||
/// Play a short menu haptic tick on the menu pad (no-op while a session is attached
|
||||
/// or no pad is open; best-effort on pads without rumble).
|
||||
pub fn menu_rumble(&self, pulse: MenuPulse) {
|
||||
let _ = self.ctl.send(Ctl::MenuRumble(pulse));
|
||||
}
|
||||
|
||||
pub fn pads(&self) -> Vec<PadInfo> {
|
||||
self.pads.lock().unwrap().clone()
|
||||
}
|
||||
@@ -363,6 +563,11 @@ struct Worker<'a> {
|
||||
chord_since: Option<Instant>,
|
||||
/// The disconnect signal already fired for the current hold — latched so it fires once.
|
||||
disconnect_fired: bool,
|
||||
/// Menu mode ([`GamepadService::set_menu_mode`]): hold the active pad open while idle
|
||||
/// and translate it into [`MenuEvent`]s. An attached session pauses translation.
|
||||
menu_mode: bool,
|
||||
menu_nav: MenuNav,
|
||||
menu_tx: async_channel::Sender<MenuEvent>,
|
||||
}
|
||||
|
||||
impl Worker<'_> {
|
||||
@@ -421,12 +626,12 @@ impl Worker<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Hold exactly the right device: the active pad while a session is attached, nothing
|
||||
/// otherwise. The single place that decides to open (= grab) hardware; dropping the
|
||||
/// old handle closes it (`SDL_CloseGamepad`) — on a Deck the firmware watchdog then
|
||||
/// restores lizard mode.
|
||||
/// Hold exactly the right device: the active pad while a session is attached or menu
|
||||
/// mode owns navigation, nothing otherwise. The single place that decides to open
|
||||
/// (= grab) hardware; dropping the old handle closes it (`SDL_CloseGamepad`) — on a
|
||||
/// Deck the firmware watchdog then restores lizard mode.
|
||||
fn sync_open(&mut self) {
|
||||
let want = if self.attached.is_some() {
|
||||
let want = if self.attached.is_some() || self.menu_mode {
|
||||
self.active_id()
|
||||
} else {
|
||||
None
|
||||
@@ -439,7 +644,15 @@ impl Worker<'_> {
|
||||
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
|
||||
Ok(pad) => {
|
||||
self.open = Some((id, pad));
|
||||
self.set_sensors(true);
|
||||
// Sensors stream only for an attached session (USB/BT bandwidth); the
|
||||
// menu needs buttons + stick only.
|
||||
if self.attached.is_some() {
|
||||
self.set_sensors(true);
|
||||
} else {
|
||||
// The menu pad changed under us (hot-plug while the launcher is
|
||||
// open): adopt the new pad's held state instead of firing it.
|
||||
self.menu_nav.reset();
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"),
|
||||
}
|
||||
@@ -645,14 +858,42 @@ impl Worker<'_> {
|
||||
Ok(Ctl::Detach) => {
|
||||
self.flush_held();
|
||||
self.attached = None;
|
||||
self.sync_open(); // closes the held device
|
||||
self.sync_open(); // closes the held device (menu mode keeps it)
|
||||
set_valve_hidapi(false);
|
||||
if self.menu_mode {
|
||||
// Back to the launcher: adopt whatever is still physically held
|
||||
// (the escape chord that ended the session, a lingering B) so it
|
||||
// can't ghost-fire menu actions.
|
||||
self.menu_nav.reset();
|
||||
}
|
||||
}
|
||||
Ok(Ctl::Pin(key)) => {
|
||||
let before = self.active_id();
|
||||
self.pinned = key;
|
||||
self.refresh_active(before);
|
||||
}
|
||||
Ok(Ctl::MenuMode(on)) => {
|
||||
self.menu_mode = on;
|
||||
if on {
|
||||
self.menu_nav.reset();
|
||||
}
|
||||
self.sync_open();
|
||||
}
|
||||
Ok(Ctl::MenuRumble(pulse)) => {
|
||||
if self.attached.is_none() {
|
||||
if let Some((_, pad)) = self.open.as_mut() {
|
||||
let (low, high, ms) = match pulse {
|
||||
// Light high-freq detent — won't jackhammer at repeat rate.
|
||||
MenuPulse::Move => (0, 0x3000, 25),
|
||||
// Fuller both-motor thunk.
|
||||
MenuPulse::Confirm => (0x5000, 0x5000, 60),
|
||||
// Dull low-freq wall.
|
||||
MenuPulse::Boundary => (0x6000, 0, 60),
|
||||
};
|
||||
let _ = pad.set_rumble(low, high, ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return true,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => return false, // app gone
|
||||
}
|
||||
@@ -784,6 +1025,42 @@ impl Worker<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample the open pad and translate it into [`MenuEvent`]s — only while menu mode is
|
||||
/// on and no session is attached (attach supersedes; SDL events merely wake the loop,
|
||||
/// so a press is translated the iteration it arrives).
|
||||
fn menu_poll(&mut self) {
|
||||
if !self.menu_mode || self.attached.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some((_, pad)) = self.open.as_ref() else {
|
||||
return;
|
||||
};
|
||||
use sdl3::gamepad::{Axis, Button};
|
||||
let s = MenuSample {
|
||||
buttons: [
|
||||
pad.button(Button::South),
|
||||
pad.button(Button::East),
|
||||
pad.button(Button::West),
|
||||
pad.button(Button::North),
|
||||
pad.button(Button::LeftShoulder),
|
||||
pad.button(Button::RightShoulder),
|
||||
],
|
||||
lx: pad.axis(Axis::LeftX),
|
||||
ly: pad.axis(Axis::LeftY),
|
||||
dpad: [
|
||||
pad.button(Button::DPadUp),
|
||||
pad.button(Button::DPadDown),
|
||||
pad.button(Button::DPadLeft),
|
||||
pad.button(Button::DPadRight),
|
||||
],
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
self.menu_nav.poll(&s, Instant::now(), &mut out);
|
||||
for e in out {
|
||||
let _ = self.menu_tx.try_send(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain and render the feedback planes — rumble plus HID output (lightbar /
|
||||
/// player LEDs / adaptive triggers) — on the active pad; this thread is their single
|
||||
/// consumer. The host re-sends rumble state periodically, so a generous duration with
|
||||
@@ -847,6 +1124,7 @@ fn run(
|
||||
ctl: &Receiver<Ctl>,
|
||||
escape_tx: &async_channel::Sender<()>,
|
||||
disconnect_tx: &async_channel::Sender<()>,
|
||||
menu_tx: &async_channel::Sender<MenuEvent>,
|
||||
) -> Result<(), String> {
|
||||
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
|
||||
// own thread.
|
||||
@@ -877,6 +1155,9 @@ fn run(
|
||||
chord_armed: false,
|
||||
chord_since: None,
|
||||
disconnect_fired: false,
|
||||
menu_mode: false,
|
||||
menu_nav: MenuNav::new(),
|
||||
menu_tx: menu_tx.clone(),
|
||||
};
|
||||
|
||||
loop {
|
||||
@@ -891,8 +1172,13 @@ fn run(
|
||||
// rumble/HID feedback, and the escape-chord hold check all run once per wakeup,
|
||||
// so their worst case is one timeout (~10 ms attached, imperceptible for
|
||||
// haptics; DISCONNECT_HOLD is 1500 ms, so 10 ms hold-check granularity is far
|
||||
// inside tolerance). Idle (no session) wakes lazily at 30 ms for hotplug + ctl.
|
||||
let timeout = Duration::from_millis(if w.attached.is_some() { 10 } else { 30 });
|
||||
// inside tolerance; menu mode needs the same cadence for its repeat timing).
|
||||
// Idle (no session, no menu) wakes lazily at 30 ms for hotplug + ctl.
|
||||
let timeout = Duration::from_millis(if w.attached.is_some() || w.menu_mode {
|
||||
10
|
||||
} else {
|
||||
30
|
||||
});
|
||||
if let Some(event) = pump.wait_event_timeout(timeout) {
|
||||
w.handle_event(event);
|
||||
// Drain whatever else queued while we were waiting or handling.
|
||||
@@ -905,6 +1191,169 @@ fn run(
|
||||
// new button events; the chord itself is only detected while a session is attached).
|
||||
w.maybe_fire_disconnect();
|
||||
|
||||
w.menu_poll();
|
||||
w.render_feedback();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod menu_nav_tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> MenuSample {
|
||||
MenuSample::default()
|
||||
}
|
||||
|
||||
fn events(nav: &mut MenuNav, s: &MenuSample, at: Instant) -> Vec<MenuEvent> {
|
||||
let mut out = Vec::new();
|
||||
nav.poll(s, at, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_adopts_held_state_without_firing() {
|
||||
let mut nav = MenuNav::new();
|
||||
let t = Instant::now();
|
||||
let mut held = sample();
|
||||
held.buttons[0] = true; // A held on entry
|
||||
held.lx = 30000; // stick already deflected right
|
||||
assert!(events(&mut nav, &held, t).is_empty(), "snapshot poll fired");
|
||||
// Still held: nothing (no rising edge, direction unchanged since snapshot).
|
||||
assert!(events(&mut nav, &held, t + Duration::from_millis(10)).is_empty());
|
||||
// Release, then press again → now it fires.
|
||||
assert!(events(&mut nav, &sample(), t + Duration::from_millis(20)).is_empty());
|
||||
assert_eq!(
|
||||
events(&mut nav, &held, t + Duration::from_millis(30)),
|
||||
vec![MenuEvent::Confirm, MenuEvent::Move(MenuDir::Right)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buttons_fire_on_rising_edge_only() {
|
||||
let mut nav = MenuNav::new();
|
||||
let t = Instant::now();
|
||||
events(&mut nav, &sample(), t); // consume the snapshot
|
||||
let mut s = sample();
|
||||
s.buttons[1] = true; // B down
|
||||
assert_eq!(
|
||||
events(&mut nav, &s, t + Duration::from_millis(10)),
|
||||
vec![MenuEvent::Back]
|
||||
);
|
||||
for i in 2..20 {
|
||||
assert!(
|
||||
events(&mut nav, &s, t + Duration::from_millis(10 * i)).is_empty(),
|
||||
"held button re-fired"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_rearms_the_snapshot() {
|
||||
let mut nav = MenuNav::new();
|
||||
let t = Instant::now();
|
||||
events(&mut nav, &sample(), t);
|
||||
nav.reset();
|
||||
let mut s = sample();
|
||||
s.buttons[1] = true;
|
||||
assert!(
|
||||
events(&mut nav, &s, t + Duration::from_millis(10)).is_empty(),
|
||||
"post-reset poll fired a held button"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direction_repeats_after_delay_at_interval() {
|
||||
let mut nav = MenuNav::new();
|
||||
let t = Instant::now();
|
||||
events(&mut nav, &sample(), t);
|
||||
let mut s = sample();
|
||||
s.dpad[3] = true; // dpad right
|
||||
// Engage: fires immediately.
|
||||
assert_eq!(
|
||||
events(&mut nav, &s, t + Duration::from_millis(10)),
|
||||
vec![MenuEvent::Move(MenuDir::Right)]
|
||||
);
|
||||
// Inside the initial delay: silent.
|
||||
assert!(events(&mut nav, &s, t + Duration::from_millis(300)).is_empty());
|
||||
// Past the delay: repeats…
|
||||
assert_eq!(
|
||||
events(&mut nav, &s, t + Duration::from_millis(400)),
|
||||
vec![MenuEvent::Move(MenuDir::Right)]
|
||||
);
|
||||
// …but not faster than the interval…
|
||||
assert!(events(&mut nav, &s, t + Duration::from_millis(500)).is_empty());
|
||||
// …and again once it elapses.
|
||||
assert_eq!(
|
||||
events(&mut nav, &s, t + Duration::from_millis(570)),
|
||||
vec![MenuEvent::Move(MenuDir::Right)]
|
||||
);
|
||||
// Release cancels; re-engage fires immediately again.
|
||||
assert!(events(&mut nav, &sample(), t + Duration::from_millis(580)).is_empty());
|
||||
assert_eq!(
|
||||
events(&mut nav, &s, t + Duration::from_millis(590)),
|
||||
vec![MenuEvent::Move(MenuDir::Right)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direction_change_fires_immediately() {
|
||||
let mut nav = MenuNav::new();
|
||||
let t = Instant::now();
|
||||
events(&mut nav, &sample(), t);
|
||||
let mut right = sample();
|
||||
right.lx = 30000;
|
||||
let mut left = sample();
|
||||
left.lx = -30000;
|
||||
assert_eq!(
|
||||
events(&mut nav, &right, t + Duration::from_millis(10)),
|
||||
vec![MenuEvent::Move(MenuDir::Right)]
|
||||
);
|
||||
assert_eq!(
|
||||
events(&mut nav, &left, t + Duration::from_millis(20)),
|
||||
vec![MenuEvent::Move(MenuDir::Left)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direction_resolution() {
|
||||
// Below the deadzone: nothing.
|
||||
let mut s = sample();
|
||||
s.lx = MENU_DEADZONE as i16;
|
||||
assert_eq!(MenuNav::resolve_dir(&s), None);
|
||||
// Dominant axis wins; SDL +y = down.
|
||||
s.lx = 20000;
|
||||
s.ly = 25000;
|
||||
assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Down));
|
||||
s.ly = -25000;
|
||||
assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Up));
|
||||
s.lx = 26000;
|
||||
assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Right));
|
||||
s.lx = -26000;
|
||||
assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Left));
|
||||
// Dpad fallback…
|
||||
let mut d = sample();
|
||||
d.dpad[1] = true;
|
||||
assert_eq!(MenuNav::resolve_dir(&d), Some(MenuDir::Down));
|
||||
// …but the stick overrides it.
|
||||
d.lx = 30000;
|
||||
assert_eq!(MenuNav::resolve_dir(&d), Some(MenuDir::Right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shoulder_and_face_button_mapping() {
|
||||
let mut nav = MenuNav::new();
|
||||
let t = Instant::now();
|
||||
events(&mut nav, &sample(), t);
|
||||
let mut s = sample();
|
||||
s.buttons = [false, false, true, true, true, true]; // x, y, l1, r1
|
||||
assert_eq!(
|
||||
events(&mut nav, &s, t + Duration::from_millis(10)),
|
||||
vec![
|
||||
MenuEvent::Tertiary,
|
||||
MenuEvent::Secondary,
|
||||
MenuEvent::JumpBack,
|
||||
MenuEvent::JumpForward,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,18 +299,24 @@ impl SessionUi {
|
||||
}
|
||||
// A pinned connect rejected on trust grounds means the host's cert no
|
||||
// longer matches the stored pin (rotated cert or impostor) — route to
|
||||
// the PIN ceremony to re-establish trust rather than dead-ending.
|
||||
if trust_rejected && !self.tofu {
|
||||
// the PIN ceremony to re-establish trust rather than dead-ending. Browse
|
||||
// mode can't: gamescope never maps dialogs, so it renders the advice instead
|
||||
// (re-pairing is the plugin's job there).
|
||||
if trust_rejected && !self.tofu && self.app.browse_ui().is_none() {
|
||||
self.app
|
||||
.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
||||
crate::ui_trust::pin_dialog(self.app.clone(), self.req.clone());
|
||||
} else if trust_rejected && !self.tofu {
|
||||
self.app
|
||||
.connect_error("Host identity changed — re-pair from the Punktfunk plugin.");
|
||||
} else {
|
||||
// Errors land on the hosts page banner, not a transient toast.
|
||||
// Errors land on the hosts page banner / launcher strip, not a transient toast.
|
||||
self.app.connect_error(&format!("Couldn't connect — {msg}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Ended`: detach gamepads, pop back to the hosts page, and surface the reason.
|
||||
/// `Ended`: detach gamepads, pop back to the launcher (browse mode) or the hosts
|
||||
/// page, and surface the reason.
|
||||
fn on_ended(&mut self, err: Option<String>) {
|
||||
self.close_waiting();
|
||||
self.app.gamepad.detach();
|
||||
@@ -324,6 +330,18 @@ impl SessionUi {
|
||||
self.app.window.close();
|
||||
return;
|
||||
}
|
||||
// Browse mode: back to the launcher to pick the next game — B there quits to
|
||||
// Gaming Mode. (The gamepad worker re-opened the pad and armed the held-state
|
||||
// snapshot on the detach above, so the chord that ended the session fires nothing.)
|
||||
if let Some(l) = self.app.browse_ui() {
|
||||
self.app.nav.pop_to_tag("launcher");
|
||||
l.on_session_ended();
|
||||
if let Some(e) = err {
|
||||
self.app.connect_error(&e);
|
||||
}
|
||||
self.app.busy.set(false);
|
||||
return;
|
||||
}
|
||||
self.app.nav.pop_to_tag("hosts");
|
||||
if let Some(h) = self.app.hosts_ui() {
|
||||
h.set_connecting(None);
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
//! verified by its pinned SHA-256 fingerprint (`KnownHost::fp_hex`), not a CA chain.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// The management API's default port — matches `mgmt::DEFAULT_PORT` on the host. A
|
||||
@@ -181,6 +182,57 @@ pub fn fetch_art(pinned: &ureq::Agent, base: &str, url: &str) -> Result<Vec<u8>,
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Concurrent poster fetches — a handful is plenty for a LAN art proxy without turning a
|
||||
/// big library into a connection burst.
|
||||
const ART_WORKERS: usize = 3;
|
||||
|
||||
/// Fetch poster bytes for `jobs` (entry id → candidate URLs, walked in order until one
|
||||
/// loads) on a small worker pool; results stream on the returned channel as they land.
|
||||
/// Dropping the receiver (the consuming page popped) winds the workers down. Shared by
|
||||
/// the touch grid and the gamepad launcher — the consumer does its own texture decode on
|
||||
/// the main loop.
|
||||
pub fn spawn_art_fetch(
|
||||
base: String,
|
||||
identity: (String, String),
|
||||
pin: Option<[u8; 32]>,
|
||||
jobs: VecDeque<(String, Vec<String>)>,
|
||||
) -> async_channel::Receiver<(String, Vec<u8>)> {
|
||||
let queue = Arc::new(Mutex::new(jobs));
|
||||
let (tx, rx) = async_channel::unbounded::<(String, Vec<u8>)>();
|
||||
for _ in 0..ART_WORKERS {
|
||||
let queue = queue.clone();
|
||||
let tx = tx.clone();
|
||||
let base = base.clone();
|
||||
let identity = identity.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-lib-art".into())
|
||||
.spawn(move || {
|
||||
let Ok(agent) = agent(&identity, pin) else {
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
let job = queue.lock().unwrap().pop_front();
|
||||
let Some((id, candidates)) = job else { break };
|
||||
for url in &candidates {
|
||||
match fetch_art(&agent, &base, url) {
|
||||
Ok(bytes) => {
|
||||
// Receiver gone (page popped) — stop fetching.
|
||||
if tx.send_blocking((id, bytes)).is_err() {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// 404 on a guessed CDN path is routine — try the next kind.
|
||||
Err(e) => tracing::debug!(%id, url, error = %e, "poster miss"),
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawn art thread");
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
||||
fn classify(e: ureq::Error) -> LibraryError {
|
||||
match e {
|
||||
ureq::Error::Status(401 | 403, _) => LibraryError::NotPaired,
|
||||
|
||||
@@ -26,6 +26,8 @@ mod session;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod trust;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_gamepad_library;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_hosts;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_library;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,6 @@ use gtk::{gdk, glib};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Concurrent poster fetches — a handful is plenty for a LAN art proxy without turning a
|
||||
/// big library into a connection burst.
|
||||
const ART_WORKERS: usize = 3;
|
||||
|
||||
/// Everything the page re-renders from. Kept alive by the widget closures (reload/retry/
|
||||
/// card activation); dropped when the page is popped, which also winds down any in-flight
|
||||
@@ -295,39 +290,7 @@ fn load_art(state: &Rc<State>, games: &[GameEntry]) {
|
||||
}
|
||||
let identity = state.app.identity.clone();
|
||||
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||
let queue = Arc::new(Mutex::new(jobs));
|
||||
let (tx, rx) = async_channel::unbounded::<(String, Vec<u8>)>();
|
||||
for _ in 0..ART_WORKERS {
|
||||
let queue = queue.clone();
|
||||
let tx = tx.clone();
|
||||
let base = base.clone();
|
||||
let identity = identity.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-lib-art".into())
|
||||
.spawn(move || {
|
||||
let Ok(agent) = library::agent(&identity, pin) else {
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
let job = queue.lock().unwrap().pop_front();
|
||||
let Some((id, candidates)) = job else { break };
|
||||
for url in &candidates {
|
||||
match library::fetch_art(&agent, &base, url) {
|
||||
Ok(bytes) => {
|
||||
// Receiver gone (page popped) — stop fetching.
|
||||
if tx.send_blocking((id, bytes)).is_err() {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// 404 on a guessed CDN path is routine — try the next kind.
|
||||
Err(e) => tracing::debug!(%id, url, error = %e, "poster miss"),
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawn art thread");
|
||||
}
|
||||
let rx = library::spawn_art_fetch(base, identity, pin, jobs);
|
||||
let weak = Rc::downgrade(state);
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok((id, bytes)) = rx.recv().await {
|
||||
@@ -349,7 +312,8 @@ fn load_art(state: &Rc<State>, games: &[GameEntry]) {
|
||||
|
||||
/// The store badge text — `store` comes from the entry (today `steam`/`custom`; future
|
||||
/// stores per the host's provider list), with the id prefix as a fallback spelling.
|
||||
fn store_label(store: &str) -> &'static str {
|
||||
/// Shared with the gamepad launcher's posters.
|
||||
pub fn store_label(store: &str) -> &'static str {
|
||||
match store {
|
||||
"steam" => "Steam",
|
||||
"custom" => "Custom",
|
||||
@@ -363,7 +327,8 @@ fn store_label(store: &str) -> &'static str {
|
||||
}
|
||||
|
||||
/// Monogram for the placeholder tile: the first letters of the first two words.
|
||||
fn initials(title: &str) -> String {
|
||||
/// Shared with the gamepad launcher's posters.
|
||||
pub fn initials(title: &str) -> String {
|
||||
title
|
||||
.split_whitespace()
|
||||
.take(2)
|
||||
|
||||
@@ -27,7 +27,7 @@ GEOMETRY="${GEOMETRY:-1380x860x24}"
|
||||
SETTLE="${SETTLE:-1.2}"
|
||||
SHOT_DISPLAY="${SHOT_DISPLAY:-:99}"
|
||||
|
||||
if [ "$#" -gt 0 ]; then SCENES=("$@"); else SCENES=(hosts settings trust pair addhost shortcuts library); fi
|
||||
if [ "$#" -gt 0 ]; then SCENES=("$@"); else SCENES=(hosts settings trust pair addhost shortcuts library gamepad-library); fi
|
||||
|
||||
[ -x "$BIN" ] || {
|
||||
echo "client binary not found: $BIN (build it first: cargo build --release -p punktfunk-client-linux)" >&2
|
||||
|
||||
Reference in New Issue
Block a user