From e925d001942224c825208166d623ecf051407acf Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 2 Jul 2026 11:04:43 +0200 Subject: [PATCH] feat(linux): game library browser; split app.rs into cli/launch/ui_trust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - library.rs + ui_library.rs: the host's unified game library over the management API (the Apple LibraryClient/LibraryView ported) — mTLS with the paired identity, host verified by its pinned cert fingerprint (ureq + rustls, unified with the workspace rustls 0.23); posters load async with monogram placeholders, and picking a title starts a session that asks the host to launch it (the library id rides the Hello). - app.rs (~800 lines lighter) splits into cli.rs (argv/headless pairing/--connect/screenshot scenes), launch.rs (mode resolve + session worker + event stream into the UI) and ui_trust.rs (TOFU / SPAKE2 PIN / delegated-approval dialogs); ui_hosts/ui_stream reworked around the split. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 2 + clients/linux/Cargo.toml | 5 + clients/linux/README.md | 21 +- clients/linux/src/app.rs | 839 +++++++--------------------- clients/linux/src/audio.rs | 28 +- clients/linux/src/cli.rs | 305 ++++++++++ clients/linux/src/discovery.rs | 81 ++- clients/linux/src/gamepad.rs | 491 +++++++++-------- clients/linux/src/launch.rs | 320 +++++++++++ clients/linux/src/library.rs | 312 +++++++++++ clients/linux/src/main.rs | 10 + clients/linux/src/session.rs | 132 +++-- clients/linux/src/trust.rs | 68 +++ clients/linux/src/ui_hosts.rs | 859 +++++++++++++++++++++-------- clients/linux/src/ui_library.rs | 386 +++++++++++++ clients/linux/src/ui_settings.rs | 58 +- clients/linux/src/ui_stream.rs | 736 +++++++++++++++--------- clients/linux/src/ui_trust.rs | 266 +++++++++ clients/linux/src/video.rs | 84 ++- clients/linux/tools/screenshots.sh | 112 ++-- 20 files changed, 3591 insertions(+), 1524 deletions(-) create mode 100644 clients/linux/src/cli.rs create mode 100644 clients/linux/src/launch.rs create mode 100644 clients/linux/src/library.rs create mode 100644 clients/linux/src/ui_library.rs create mode 100644 clients/linux/src/ui_trust.rs diff --git a/Cargo.lock b/Cargo.lock index 201813b..1f5a71b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2745,11 +2745,13 @@ dependencies = [ "opus", "pipewire", "punktfunk-core", + "rustls", "sdl3", "serde", "serde_json", "tracing", "tracing-subscriber", + "ureq", ] [[package]] diff --git a/clients/linux/Cargo.toml b/clients/linux/Cargo.toml index 23971c1..005b898 100644 --- a/clients/linux/Cargo.toml +++ b/clients/linux/Cargo.toml @@ -33,6 +33,11 @@ pipewire = "0.9" sdl3 = { version = "0.18", features = ["hidapi"] } mdns-sd = "0.20" +# Game-library fetch from the host's management API over mTLS + fingerprint pinning. +# `ureq` is small + sync (the host uses it too) and its rustls unifies with the +# workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`. +ureq = "2" +rustls = { version = "0.23", features = ["ring"] } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" diff --git a/clients/linux/README.md b/clients/linux/README.md index 9f08ba2..f01fcec 100644 --- a/clients/linux/README.md +++ b/clients/linux/README.md @@ -22,6 +22,10 @@ Built in Rust, it links the shared **`punktfunk-core`** directly (no C ABI) and First connect does a one-time **SPAKE2 PIN pairing** (or TOFU on trusted LANs), then reconnects on a pinned identity. - **Per-host speed test** to pick a bitrate, plus compositor and mode preferences in Settings. +- **Game library browser** *(experimental, off by default)* — "Browse library…" on a saved host + 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. ## Get it @@ -51,23 +55,28 @@ cargo run -p punktfunk-client-linux -- --connect HOST[:PORT] # skip the host l 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 -`--pair --connect host[:port]` (run the pairing ceremony headlessly). Force a decoder with +`--pair --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`. ## Layout ``` src/ - main.rs · app.rs entry point, GTK application, CLI paths - ui_hosts.rs host list (mDNS + saved), pairing / trust dialogs + main.rs · app.rs entry point, GTK application, primary menu, CSS + cli.rs CLI paths (--connect, 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_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 - session.rs session lifecycle over the NativeClient connector + launch.rs · session.rs session launch/UI glue; lifecycle over the NativeClient connector video.rs FFmpeg VAAPI / software decode → dmabuf / texture audio.rs PipeWire playback + mic uplink gamepad.rs · keymap.rs SDL3 controllers + feedback; keyboard VK mapping - trust.rs · discovery.rs persistent identity, TOFU/PIN pairing, mDNS browse -tools/screenshots.sh store screenshot capture + trust.rs · discovery.rs persistent identity, known hosts + settings, mDNS browse + library.rs mgmt-API library client (mTLS + pinned fingerprint, art proxy) +tools/screenshots.sh store screenshot capture (app self-capture; Xvfb fallback) ``` ## Related diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index 623a582..a59b934 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -1,10 +1,11 @@ -//! The application shell: window, navigation, trust dialogs, session lifecycle. +//! The application shell: window, navigation, and top-level glue. The trust/pairing +//! dialogs live in `ui_trust`, session launch in `launch`, CLI entry paths in `cli`, the +//! hosts grid in `ui_hosts`. -use crate::session::{SessionEvent, SessionParams}; -use crate::trust::{KnownHost, KnownHosts, Settings}; -use crate::ui_hosts::ConnectRequest; +use crate::trust::Settings; +use crate::ui_hosts::{ConnectRequest, HostsCallbacks, HostsUi}; use adw::prelude::*; -use gtk::{gdk, glib}; +use gtk::{gdk, gio, glib}; use punktfunk_core::client::NativeClient; use punktfunk_core::config::{CompositorPref, GamepadPref}; use std::cell::RefCell; @@ -12,24 +13,58 @@ use std::rc::Rc; const APP_ID: &str = "io.unom.Punktfunk"; -struct App { - window: adw::ApplicationWindow, - nav: adw::NavigationView, - toasts: adw::ToastOverlay, - settings: Rc>, - identity: (String, String), +/// Custom styles on top of libadwaita for the host cards: status pills, presence pips, +/// the most-recent accent bar, dashed discovered cards. Colours come from the adwaita +/// named palette so dark mode just works. +const CSS: &str = " +.pf-host-card { padding: 16px; } +.pf-pill { font-size: 0.72em; font-weight: bold; padding: 2px 10px; border-radius: 999px; + color: alpha(currentColor, 0.8); background: alpha(currentColor, 0.1); } +.pf-pill.pf-green { color: @success_color; background: alpha(@success_color, 0.15); } +.pf-pill.pf-accent { color: @accent_color; background: alpha(@accent_color, 0.15); } +.pf-pip { min-width: 8px; min-height: 8px; border-radius: 999px; + background: alpha(currentColor, 0.35); } +.pf-pip.pf-online { background: @success_color; } +.pf-recent { box-shadow: inset 3px 0 0 0 @accent_bg_color; } +.pf-discovered { border: 1px dashed alpha(currentColor, 0.35); } +.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); } +.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); } +.pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); } +"; + +pub struct App { + pub window: adw::ApplicationWindow, + pub nav: adw::NavigationView, + pub toasts: adw::ToastOverlay, + pub settings: Rc>, + pub identity: (String, String), /// App-lifetime SDL gamepad service: Settings list + per-session capture/feedback. - gamepad: crate::gamepad::GamepadService, + pub gamepad: crate::gamepad::GamepadService, /// One session at a time — ignore connects while one is starting/running. - busy: std::cell::Cell, + pub busy: std::cell::Cell, /// Steam Deck / Gaming-Mode launch: fullscreen the window (chrome-less) when a stream starts. - fullscreen: bool, + pub fullscreen: bool, + /// The hosts page handle (banner + per-card connecting spinner), set right after the + /// page is built — `None` only during construction. + pub hosts: RefCell>>, } impl App { - fn toast(&self, msg: &str) { + pub fn toast(&self, msg: &str) { self.toasts.add_toast(adw::Toast::new(msg)); } + + pub fn hosts_ui(&self) -> Option> { + self.hosts.borrow().clone() + } + + /// Surface a connect failure on 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), + } + } } pub fn run() -> glib::ExitCode { @@ -40,13 +75,17 @@ pub fn run() -> glib::ExitCode { .init(); // Headless pairing path (no GTK window): `--pair --connect host[:port] [--name N]`. // Used by the Decky plugin (a GTK dialog can't pop under gamescope) and for scripting. - if let Some(pin) = arg_value("--pair") { - return headless_pair(&pin); + if let Some(pin) = crate::cli::arg_value("--pair") { + return crate::cli::headless_pair(&pin); + } + // Headless library fetch (no GTK window): `--library host[:mgmt_port] [--fp HEX]`. + if let Some(target) = crate::cli::arg_value("--library") { + return crate::cli::headless_library(&target); } let mut builder = adw::Application::builder().application_id(APP_ID); // Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps each // launch its own primary instance instead of forwarding to a still-registered name. - if shot_scene().is_some() { + if crate::cli::shot_scene().is_some() { builder = builder.flags(gtk::gio::ApplicationFlags::NON_UNIQUE); } let app = builder.build(); @@ -56,105 +95,6 @@ pub fn run() -> glib::ExitCode { app.run_with_args(&[] as &[&str]) } -/// The value following `flag` in argv, if present (`--flag value`). -fn arg_value(flag: &str) -> Option { - std::env::args() - .skip_while(|a| a != flag) - .nth(1) - .filter(|v| !v.starts_with("--")) -} - -/// True if argv contains `flag` (a valueless switch). -fn arg_flag(flag: &str) -> bool { - std::env::args().any(|a| a == flag) -} - -/// Run the stream fullscreen with no window chrome — the Steam Deck / Gaming-Mode launch path. -/// The Decky wrapper passes `--fullscreen`; we also honor the Deck/gamescope env as a fallback -/// so a manual launch under Gaming Mode does the right thing too. -fn fullscreen_mode() -> bool { - arg_flag("--fullscreen") - || std::env::var_os("SteamDeck").is_some() - || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() -} - -/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the -/// known-hosts store as paired, so a later `--connect` connects silently. Same identity -/// store the streaming path uses (same binary), so pairing here makes the stream work. -/// Prints a one-line `paired : fp=` on success; exits non-zero on failure. -fn headless_pair(pin: &str) -> glib::ExitCode { - let Some(target) = arg_value("--connect") else { - eprintln!("--pair requires --connect host[:port]"); - return glib::ExitCode::FAILURE; - }; - let (addr, port) = match target.rsplit_once(':') { - Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)), - None => (target.clone(), 9777), - }; - // The label the HOST stores this client under (its paired-devices list). - let name = arg_value("--name").unwrap_or_else(|| "Steam Deck".to_string()); - - let identity = match crate::trust::load_or_create_identity() { - Ok(i) => i, - Err(e) => { - eprintln!("client identity: {e:#}"); - return glib::ExitCode::FAILURE; - } - }; - match NativeClient::pair( - &addr, - port, - (&identity.0, &identity.1), - pin.trim(), - &name, - std::time::Duration::from_secs(90), - ) { - Ok(fp) => { - let fp_hex = crate::trust::hex(&fp); - let mut known = KnownHosts::load(); - known.upsert(KnownHost { - name: arg_value("--host-label").unwrap_or_else(|| addr.clone()), - addr: addr.clone(), - port, - fp_hex: fp_hex.clone(), - paired: true, - }); - let _ = known.save(); - println!("paired {addr}:{port} fp={fp_hex}"); - glib::ExitCode::SUCCESS - } - Err(e) => { - eprintln!("pairing failed: {e:?} (wrong PIN, or pairing not armed on the host?)"); - glib::ExitCode::FAILURE - } - } -} - -/// `--connect host[:port]` — skip the hosts page and start a session immediately -/// (scripting + headless testing). Trust follows the same rules as a manual entry: a host -/// 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). -fn cli_connect_request() -> Option { - let args: Vec = std::env::args().collect(); - let target = args - .iter() - .skip_while(|a| *a != "--connect") - .nth(1)? - .clone(); - let (addr, port) = match target.rsplit_once(':') { - Some((a, p)) => (a.to_string(), p.parse().ok()?), - None => (target.clone(), 9777), - }; - Some(ConnectRequest { - name: addr.clone(), - addr, - port, - fp_hex: None, - pair_optional: false, - }) -} - fn build_ui(gtk_app: &adw::Application) { let identity = match crate::trust::load_or_create_identity() { Ok(i) => i, @@ -163,6 +103,7 @@ fn build_ui(gtk_app: &adw::Application) { std::process::exit(1); } }; + load_css(); let nav = adw::NavigationView::new(); let toasts = adw::ToastOverlay::new(); @@ -170,8 +111,8 @@ fn build_ui(gtk_app: &adw::Application) { let window = adw::ApplicationWindow::builder() .application(gtk_app) .title("Punktfunk") - .default_width(1100) - .default_height(720) + .default_width(1200) + .default_height(780) .content(&toasts) .build(); @@ -183,318 +124,159 @@ fn build_ui(gtk_app: &adw::Application) { identity, gamepad: crate::gamepad::GamepadService::start(), busy: std::cell::Cell::new(false), - fullscreen: fullscreen_mode(), + fullscreen: crate::cli::fullscreen_mode(), + hosts: RefCell::new(None), }); - let hosts_page = crate::ui_hosts::new( - { - let app = app.clone(); - Rc::new(move |req| initiate_connect(app.clone(), req)) + let hosts_ui = Rc::new(crate::ui_hosts::new( + app.settings.clone(), + HostsCallbacks { + on_connect: { + let app = app.clone(); + Rc::new(move |req| crate::ui_trust::initiate_connect(app.clone(), req)) + }, + on_speed_test: { + let app = app.clone(); + Rc::new(move |req| speed_test(app.clone(), req)) + }, + on_pair: { + let app = app.clone(); + Rc::new(move |req| { + if !app.busy.get() { + crate::ui_trust::pin_dialog(app.clone(), req); + } + }) + }, + on_library: { + let app = app.clone(); + Rc::new(move |req| crate::ui_library::open(app.clone(), req)) + }, }, - { - let app = app.clone(); - Rc::new(move || { - crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad) - }) - }, - { - let app = app.clone(); - Rc::new(move |req| speed_test(app.clone(), req)) - }, - ); - nav.add(&hosts_page); + )); + *app.hosts.borrow_mut() = Some(hosts_ui.clone()); + install_actions(&app, &hosts_ui); + nav.add(&hosts_ui.page); window.present(); // CI screenshot mode: render one scripted, host-free scene and signal readiness // (clients/linux/tools/screenshots.sh). Mutually exclusive with a real connect. - if let Some(scene) = shot_scene() { - run_shot(app, &scene); + if let Some(scene) = crate::cli::shot_scene() { + crate::cli::run_shot(app, &scene); return; } - if let Some(req) = cli_connect_request() { - initiate_connect(app, req); + if let Some(req) = crate::cli::cli_connect_request() { + crate::ui_trust::initiate_connect(app, req); } } -/// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots. -fn shot_scene() -> Option { - std::env::var("PUNKTFUNK_SHOT_SCENE") - .ok() - .filter(|s| !s.is_empty()) +fn load_css() { + let provider = gtk::CssProvider::new(); + provider.load_from_string(CSS); + if let Some(display) = gdk::Display::default() { + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } } -/// Render one mock-populated, host-free scene over the already-presented window, then print -/// `PF_SHOT_READY` once it has had a moment to map + settle so the driver knows when to capture. -/// No `NativeClient` or session is created. The stream scene is deliberately absent — its page -/// requires a live connector (`ui_stream::new` takes an `Arc`). -fn run_shot(app: Rc, scene: &str) { - // A plausible host for the trust/pair dialogs (fp_hex is 64 hex chars, like a real SHA-256). - let mock_req = || ConnectRequest { - name: "Living Room PC".to_string(), - addr: "192.168.1.42".to_string(), - port: 9777, - fp_hex: Some( - "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00".to_string(), - ), - pair_optional: true, +/// Window actions behind the hosts page's header: the primary (hamburger) menu entries +/// plus the "+" add-host button and the empty state's call to action. +fn install_actions(app: &Rc, hosts: &Rc) { + let add = |name: &str, f: Box| { + let action = gio::SimpleAction::new(name, None); + action.connect_activate(move |_, _| f()); + app.window.add_action(&action); }; - - match scene { - // The saved-hosts grid reads ~/.config/punktfunk/client-known-hosts.json, which the - // driver seeds — so the already-shown hosts page is the scene; nothing to do here. - "hosts" | "02-hosts" => {} - "settings" | "03-settings" => { - crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad); - } - "trust" | "04-trust" => tofu_dialog(app.clone(), mock_req()), - "pair" | "05-pair" => pin_dialog(app.clone(), mock_req()), - other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"), - } - - let settle_ms = std::env::var("PUNKTFUNK_SHOT_SETTLE_MS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(900); - let scene = scene.to_string(); - glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || { - use std::io::Write as _; - println!("PF_SHOT_READY scene={scene}"); - let _ = std::io::stdout().flush(); - }); -} - -/// The trust gate in front of every connect. The host is the policy authority (it -/// advertises `pair=optional` only when it accepts unpaired clients); the client renders -/// its trust UI from that: -/// 1. PINNED RECONNECT — a host already pinned to this exact fingerprint connects silently. -/// 2. FINGERPRINT CHANGED — a host we know at this address but whose fingerprint no longer -/// matches is the impostor signal: force re-pairing via the PIN ceremony, regardless of -/// the advertised policy. -/// 3. NEW host — TOFU is offered only when the host advertised `pair=optional` (rule 3a); -/// otherwise (pair=required, unknown/empty policy, or a manual entry) PIN pairing is -/// mandatory (rule 3b). -/// -/// A new host is never auto-connected without a stored pin or an explicit trust decision. -fn initiate_connect(app: Rc, req: ConnectRequest) { - if app.busy.get() { - return; - } - let known = KnownHosts::load(); - match &req.fp_hex { - Some(fp_hex) => { - if known.find_by_fp(fp_hex).is_some() { - // Rule 1: pinned fingerprint matches — silent connect. - start_session(app, req.clone(), crate::trust::parse_hex32(fp_hex)); - } else if known.find_by_addr(&req.addr, req.port).is_some() { - // Rule 2: we trust a host at this address but the fingerprint changed — - // the impostor signal. Re-pair via the PIN ceremony (no TOFU shortcut). - app.toast("Host fingerprint changed — re-pair with a PIN to continue"); - pin_dialog(app, req); - } else if req.pair_optional { - // Rule 3a: the host opted into reduced-security TOFU; offer it alongside PIN. - tofu_dialog(app, req); - } else { - // Rule 3b: pair=required or unknown policy — offer no-PIN delegated approval - // (request access → approve in the console) or the PIN ceremony. - approval_dialog(app, req); - } - } - None => { - // Manual entry (no advertised fingerprint). A known address connects silently - // on its stored pin (rule 1); an unknown one must pair — request access (approve in - // the console) or use a PIN; never silent TOFU. - match known - .find_by_addr(&req.addr, req.port) - .and_then(|k| crate::trust::parse_hex32(&k.fp_hex)) - { - Some(pin) => start_session(app, req, Some(pin)), - None => approval_dialog(app, req), // rule 3b - } - } - } -} - -/// First contact with a discovered host: show the advertised fingerprint and let the user -/// trust it (TOFU), run the PIN ceremony instead, or walk away. -fn tofu_dialog(app: Rc, req: ConnectRequest) { - let fp = req.fp_hex.clone().unwrap_or_default(); - let dialog = adw::AlertDialog::new( - Some("New Host"), - Some(&format!( - "{} at {}:{}\n\nCertificate fingerprint:\n{}\n\nPairing with a PIN verifies it; \ - trusting accepts it as-is.", - req.name, req.addr, req.port, fp - )), - ); - dialog.add_responses(&[ - ("cancel", "Cancel"), - ("pair", "Pair with PIN…"), - ("trust", "Trust & Connect"), - ]); - dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("trust")); - dialog.set_close_response("cancel"); - let parent = app.window.clone(); - dialog.connect_response(None, move |_, response| match response { - "trust" => { - let mut known = KnownHosts::load(); - known.upsert(KnownHost { - name: req.name.clone(), - addr: req.addr.clone(), - port: req.port, - fp_hex: fp.clone(), - paired: false, - }); - let _ = known.save(); - start_session(app.clone(), req.clone(), crate::trust::parse_hex32(&fp)); - } - "pair" => pin_dialog(app.clone(), req.clone()), - _ => {} - }); - dialog.present(Some(&parent)); -} - -/// The SPAKE2 ceremony: the host is armed and displays a 4-digit PIN; proving knowledge -/// of it pins the host's certificate (and registers ours) with no offline-guessable -/// transcript. -fn pin_dialog(app: Rc, req: ConnectRequest) { - let entry = gtk::Entry::builder() - .input_purpose(gtk::InputPurpose::Digits) - .placeholder_text("4-digit PIN shown by the host") - .activates_default(true) - .build(); - let dialog = adw::AlertDialog::new( - Some("Pair with PIN"), - Some(&format!( - "Arm pairing on {} (console or web UI), then enter the PIN it displays.", - req.name - )), - ); - dialog.set_extra_child(Some(&entry)); - dialog.add_responses(&[("cancel", "Cancel"), ("pair", "Pair")]); - dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("pair")); - dialog.set_close_response("cancel"); - let parent = app.window.clone(); - dialog.connect_response(Some("pair"), move |_, _| { - let pin = entry.text().to_string(); - let app = app.clone(); - let req = req.clone(); - let identity = app.identity.clone(); - let (tx, rx) = async_channel::bounded::>(1); - let (host, port, name) = (req.addr.clone(), req.port, glib::host_name().to_string()); - std::thread::spawn(move || { - let result = NativeClient::pair( - &host, - port, - (&identity.0, &identity.1), - pin.trim(), - &name, - std::time::Duration::from_secs(90), - ) - .map_err(|e| format!("Pairing failed: {e:?} (wrong PIN, or pairing not armed?)")); - let _ = tx.send_blocking(result); - }); - glib::spawn_future_local(async move { - match rx.recv().await { - Ok(Ok(fp)) => { - let fp_hex = crate::trust::hex(&fp); - let mut known = KnownHosts::load(); - known.upsert(KnownHost { - name: req.name.clone(), - addr: req.addr.clone(), - port: req.port, - fp_hex, - paired: true, - }); - let _ = known.save(); - app.toast("Paired — connecting…"); - start_session(app.clone(), req, Some(fp)); - } - Ok(Err(msg)) => app.toast(&msg), - Err(_) => {} - } - }); - }); - dialog.present(Some(&parent)); -} - -/// A fresh host that requires pairing: offer the two ways in. "Request access" is the no-PIN -/// path — connect and wait for the operator to click Approve in the host's console/web UI -/// (delegated approval); "Use a PIN instead…" runs the SPAKE2 ceremony. -fn approval_dialog(app: Rc, req: ConnectRequest) { - let dialog = adw::AlertDialog::new( - Some("Pairing Required"), - Some(&format!( - "{} requires pairing.\n\nRequest access and approve this device in the host's console \ - (or web UI) — no PIN needed. Or pair with the 4-digit PIN it can display.", - req.name - )), - ); - dialog.add_responses(&[ - ("cancel", "Cancel"), - ("pin", "Use a PIN instead…"), - ("request", "Request Access"), - ]); - dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("request")); - dialog.set_close_response("cancel"); - let parent = app.window.clone(); - dialog.connect_response(None, move |_, response| match response { - "request" => request_access(app.clone(), req.clone()), - "pin" => pin_dialog(app.clone(), req.clone()), - _ => {} - }); - dialog.present(Some(&parent)); -} - -/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the -/// operator approves it in the console, showing a cancelable "waiting" dialog meanwhile. On -/// approval the same connection is admitted (no reconnect) and the host is saved as paired. -fn request_access(app: Rc, req: ConnectRequest) { - // Pin the advertised certificate for a discovered host (defence against a host impostor while - // we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use. - let pin = req.fp_hex.as_deref().and_then(crate::trust::parse_hex32); - let cancel = Rc::new(std::cell::Cell::new(false)); - - let waiting = adw::AlertDialog::new( - Some("Waiting for Approval"), - Some(&format!( - "Approve “{}” in {}’s console or web UI.\n\nThis device is waiting to be let in — it \ - connects automatically once you approve it.", - glib::host_name(), - req.name - )), - ); - waiting.add_responses(&[("cancel", "Cancel")]); - waiting.set_close_response("cancel"); { let app = app.clone(); - let cancel = cancel.clone(); - waiting.connect_response(Some("cancel"), move |_, _| { - // Return the UI immediately; the in-flight connect is left to time out and is torn - // down silently by the event loop (see StartOpts::cancel). - cancel.set(true); - app.busy.set(false); - app.toast("Cancelled — the request may still be pending on the host."); - }); + add( + "preferences", + Box::new(move || { + let refresh = { + let app = app.clone(); + // The library toggle changes the saved cards' menu — re-render on close. + move || { + if let Some(h) = app.hosts_ui() { + h.refresh(); + } + } + }; + crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad, refresh) + }), + ); } - waiting.present(Some(&app.window)); + { + let window = app.window.clone(); + add( + "shortcuts", + Box::new(move || shortcuts_window(&window).present()), + ); + } + { + let window = app.window.clone(); + add( + "about", + Box::new(move || crate::ui_settings::show_about(&window)), + ); + } + { + let hosts = hosts.clone(); + add("add-host", Box::new(move || hosts.show_add_host())); + } +} - start_session_with( - app, - req, - pin, - StartOpts { - // Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator - // approval still lands on this connection rather than timing the client out first. - connect_timeout: std::time::Duration::from_secs(185), - persist_paired: true, - waiting: Some(waiting), - cancel: Some(cancel), - }, - ); +/// The Keyboard Shortcuts window (menu + the shortcuts scene). GtkShortcutsWindow is +/// builder-XML-first, so it's assembled from a snippet rather than widget calls. +pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow { + const UI: &str = r#" + + + 1 + + + + + Stream + + + Toggle fullscreen + F11 + + + + + Release captured input (click the stream to capture) + <Control><Alt><Shift>q + + + + + Disconnect + <Control><Alt><Shift>d + + + + + Toggle statistics overlay + <Control><Alt><Shift>s + + + + + + + + +"#; + let builder = gtk::Builder::from_string(UI); + let window: gtk::ShortcutsWindow = builder + .object("shortcuts") + .expect("shortcuts window in builder XML"); + window.set_transient_for(Some(parent)); + window } /// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"): @@ -590,238 +372,3 @@ fn speed_test(app: Rc, req: ConnectRequest) { } }); } - -/// The mode to request: explicit settings, with `0` fields resolved to the native -/// size/refresh of the monitor the window currently occupies (mirrors the Swift client's -/// native-display default). -fn resolve_mode(app: &App) -> punktfunk_core::config::Mode { - let s = app.settings.borrow(); - let mut mode = punktfunk_core::config::Mode { - width: s.width, - height: s.height, - refresh_hz: s.refresh_hz, - }; - if mode.width == 0 || mode.refresh_hz == 0 { - // Prefer the monitor the window is on; fall back to the display's first monitor. On a - // `--connect` launch the window may not be mapped yet when this runs, and without the - // fallback we'd drop to the 1920×1080 floor below — wrong on the Deck (1280×800). - let monitor = app - .window - .surface() - .zip(gdk::Display::default()) - .and_then(|(surf, d)| d.monitor_at_surface(&surf)) - .or_else(|| { - gdk::Display::default() - .and_then(|d| d.monitors().item(0)) - .and_then(|o| o.downcast::().ok()) - }); - if let Some(m) = monitor { - let geo = m.geometry(); - let scale = m.scale_factor().max(1); - if mode.width == 0 { - mode.width = (geo.width() * scale) as u32; - mode.height = (geo.height() * scale) as u32; - } - if mode.refresh_hz == 0 { - mode.refresh_hz = ((m.refresh_rate() + 500) / 1000).max(30) as u32; - } - } - } - // No monitor info (early call, odd compositor) — a sane floor. - if mode.width == 0 { - (mode.width, mode.height) = (1920, 1080); - } - if mode.refresh_hz == 0 { - mode.refresh_hz = 60; - } - mode -} - -/// Tunables for a session start that differ between the normal connect and the "request access" -/// (delegated-approval) flow. `Default` is the normal connect. -struct StartOpts { - /// Handshake budget. The request-access flow uses a long one because the host PARKS the - /// connection until the operator clicks Approve (see the host's `PENDING_APPROVAL_WAIT`). - connect_timeout: std::time::Duration, - /// Persist the host as *paired* on a successful connect. Set for request-access, where the - /// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU - /// persists the host *unpaired* (pinned, but not PIN/approval-verified). - persist_paired: bool, - /// A "waiting for approval" dialog to dismiss on the first session event (request-access only). - waiting: Option, - /// Set by the waiting dialog's Cancel button. `NativeClient::connect` is a blocking call with - /// no abort, so Cancel returns the UI immediately (clears busy, closes the dialog) and leaves - /// the in-flight connect to time out; when it finally resolves, the event loop sees this flag - /// and tears down silently (drops the connector → closes the connection) without touching the - /// UI a new session may already own. - cancel: Option>>, -} - -impl Default for StartOpts { - fn default() -> Self { - Self { - connect_timeout: std::time::Duration::from_secs(15), - persist_paired: false, - waiting: None, - cancel: None, - } - } -} - -fn start_session(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>) { - start_session_with(app, req, pin, StartOpts::default()); -} - -fn start_session_with(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>, opts: StartOpts) { - if app.busy.replace(true) { - return; - } - let mode = resolve_mode(&app); - let s = app.settings.borrow(); - let params = SessionParams { - host: req.addr.clone(), - port: req.port, - mode, - compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto), - // "Automatic" matches the physical pad (Swift parity); an explicit choice wins. - gamepad: match GamepadPref::from_name(&s.gamepad) { - Some(GamepadPref::Auto) | None => app.gamepad.auto_pref(), - Some(explicit) => explicit, - }, - bitrate_kbps: s.bitrate_kbps, - mic_enabled: s.mic_enabled, - audio_channels: s.audio_channels, - preferred_codec: s.preferred_codec(), - pin, - identity: app.identity.clone(), - connect_timeout: opts.connect_timeout, - }; - let inhibit = s.inhibit_shortcuts; - drop(s); - let tofu = pin.is_none(); - let persist_paired = opts.persist_paired; - let mut waiting = opts.waiting; - let cancel = opts.cancel; - - let mut handle = crate::session::start(params); - let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1); - glib::spawn_future_local(async move { - let mut frames = Some(frames); - let mut page: Option = None; - while let Ok(event) = handle.events.recv().await { - // A cancelled request-access connect resolved late: tear down silently. Don't touch - // app.busy — Cancel already cleared it, and a fresh session may now own it. - if cancel.as_ref().is_some_and(|c| c.get()) { - if let Some(w) = waiting.take() { - w.close(); - } - break; - } - match event { - SessionEvent::Connected { - connector, - mode, - fingerprint, - } => { - // Dismiss the "waiting for approval" dialog (request-access flow), if any. - if let Some(w) = waiting.take() { - w.close(); - } - if persist_paired { - // Request-access: the operator approved this device, so record the host as - // a trusted PAIRED host (pinning the fingerprint we observed) — future - // connects are then silent (rule 1), exactly like after a PIN ceremony. - let fp_hex = crate::trust::hex(&fingerprint); - let mut known = KnownHosts::load(); - known.upsert(KnownHost { - name: req.name.clone(), - addr: req.addr.clone(), - port: req.port, - fp_hex, - paired: true, - }); - let _ = known.save(); - app.toast("Approved — connecting…"); - } else if tofu { - // A TOFU connect just observed the real fingerprint — pin it from now on. - let fp_hex = crate::trust::hex(&fingerprint); - let mut known = KnownHosts::load(); - known.upsert(KnownHost { - name: req.name.clone(), - addr: req.addr.clone(), - port: req.port, - fp_hex: fp_hex.clone(), - paired: false, - }); - let _ = known.save(); - app.toast(&format!( - "Trusted on first use — fingerprint {}…", - &fp_hex[..16] - )); - } - tracing::debug!(?mode, "connected — pushing stream page"); - let title = format!( - "{} · {}×{}@{}", - req.name, mode.width, mode.height, mode.refresh_hz - ); - app.gamepad.attach(connector.clone()); - let p = crate::ui_stream::new( - &app.window, - connector, - frames.take().expect("Connected delivered once"), - app.gamepad.escape_events(), - app.gamepad.disconnect_events(), - handle.stop.clone(), - inhibit, - &title, - ); - app.nav.push(&p.page); - // Steam Deck / Gaming Mode: gamescope fullscreens the window but GTK doesn't - // know it, so its header bar stays drawn. Enter GTK fullscreen explicitly — - // the stream page's `connect_fullscreened_notify` then hides all chrome. - if app.fullscreen { - app.window.fullscreen(); - } - page = Some(p); - } - SessionEvent::Stats(s) => { - if let Some(p) = &page { - p.update_stats(s); - } - } - SessionEvent::Failed { - msg, - trust_rejected, - } => { - if let Some(w) = waiting.take() { - w.close(); - } - tracing::warn!(%msg, trust_rejected, "connect failed"); - app.busy.set(false); - // 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 && !tofu { - app.toast("Host fingerprint changed — re-pair with a PIN to continue"); - pin_dialog(app.clone(), req.clone()); - } else { - app.toast(&msg); - } - break; - } - SessionEvent::Ended(err) => { - if let Some(w) = waiting.take() { - w.close(); - } - app.gamepad.detach(); - app.nav.pop_to_tag("hosts"); - if let Some(e) = err { - app.toast(&e); - } - app.busy.set(false); - break; - } - } - } - }); -} diff --git a/clients/linux/src/audio.rs b/clients/linux/src/audio.rs index e22a8ca..5bd0a1c 100644 --- a/clients/linux/src/audio.rs +++ b/clients/linux/src/audio.rs @@ -22,6 +22,9 @@ struct Terminate; pub struct AudioPlayer { pcm_tx: SyncSender>, + /// Drained chunk Vecs coming back from the PipeWire consumer for reuse (the pool half + /// of the pcm channel — see [`AudioPlayer::take_buffer`]). + recycle_rx: Receiver>, quit_tx: pipewire::channel::Sender, thread: Option>, } @@ -33,22 +36,34 @@ impl AudioPlayer { pub fn spawn(channels: u32) -> Result { // 64 × 5 ms = 320 ms of slack between the pump and the PipeWire loop. let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::>(64); + // Return path: the process callback sends each drained Vec back for reuse, so + // steady-state playback stops allocating (~200 chunks/s otherwise). Same capacity + // as the data channel; a full pool just drops the Vec (plain deallocation). + let (recycle_tx, recycle_rx) = std::sync::mpsc::sync_channel::>(64); let (quit_tx, quit_rx) = pipewire::channel::channel::(); let thread = std::thread::Builder::new() .name("punktfunk-audio".into()) .spawn(move || { - if let Err(e) = pw_thread(pcm_rx, quit_rx, channels as usize) { + if let Err(e) = pw_thread(pcm_rx, recycle_tx, quit_rx, channels as usize) { tracing::warn!(error = %e, "audio playback thread ended"); } }) .context("spawn audio thread")?; Ok(AudioPlayer { pcm_tx, + recycle_rx, quit_tx, thread: Some(thread), }) } + /// A recycled chunk Vec from the pool, empty but with its capacity intact — fill it + /// and hand it back through [`push`](Self::push). Allocates only when the pool is dry + /// (startup, or after the PipeWire side dropped chunks). + pub fn take_buffer(&self) -> Vec { + self.recycle_rx.try_recv().unwrap_or_default() + } + /// Queue one interleaved f32 chunk (in the session's channel layout). Drops the chunk if the /// PipeWire side is wedged (the renderer conceals the gap; never block the session pump). pub fn push(&self, pcm: Vec) { @@ -70,6 +85,8 @@ impl Drop for AudioPlayer { /// Producer-side state: incoming decoded PCM and the ring the process callback drains. struct PlayerData { rx: Receiver>, + /// Drained chunk Vecs go back here for the decode side to refill (allocation pool). + recycle: SyncSender>, ring: VecDeque, primed: bool, /// Interleaved channel count this stream was opened with (2/6/8). @@ -78,6 +95,7 @@ struct PlayerData { fn pw_thread( pcm_rx: Receiver>, + recycle_tx: SyncSender>, quit_rx: pipewire::channel::Receiver, channels: usize, ) -> Result<()> { @@ -117,6 +135,7 @@ fn pw_thread( let ud = PlayerData { rx: pcm_rx, + recycle: recycle_tx, ring: VecDeque::new(), primed: false, channels, @@ -132,8 +151,11 @@ fn pw_thread( let Some(mut buffer) = stream.dequeue_buffer() else { return; }; - while let Ok(chunk) = ud.rx.try_recv() { - ud.ring.extend(chunk); + while let Ok(mut chunk) = ud.rx.try_recv() { + ud.ring.extend(chunk.iter().copied()); + // Return the drained Vec to the pool; a full/closed pool drops it. + chunk.clear(); + let _ = ud.recycle.try_send(chunk); } let stride = 4 * ud.channels; // F32LE interleaved let datas = buffer.datas_mut(); diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs new file mode 100644 index 0000000..028aa98 --- /dev/null +++ b/clients/linux/src/cli.rs @@ -0,0 +1,305 @@ +//! Command-line entry paths: argv helpers, headless pairing, `--connect`, and the CI +//! screenshot scenes. + +use crate::app::App; +use crate::ui_hosts::ConnectRequest; +use gtk::glib; +use gtk::prelude::*; +use std::rc::Rc; + +/// The value following `flag` in argv, if present (`--flag value`). +pub fn arg_value(flag: &str) -> Option { + std::env::args() + .skip_while(|a| a != flag) + .nth(1) + .filter(|v| !v.starts_with("--")) +} + +/// True if argv contains `flag` (a valueless switch). +fn arg_flag(flag: &str) -> bool { + std::env::args().any(|a| a == flag) +} + +/// Run the stream fullscreen with no window chrome — the Steam Deck / Gaming-Mode launch path. +/// The Decky wrapper passes `--fullscreen`; we also honor the Deck/gamescope env as a fallback +/// so a manual launch under Gaming Mode does the right thing too. +pub fn fullscreen_mode() -> bool { + arg_flag("--fullscreen") + || std::env::var_os("SteamDeck").is_some() + || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() +} + +/// Split `host[:port]`: no colon defaults the port to 9777; a colon with an unparsable +/// port yields `None` for it (callers decide whether to default or bail). +fn parse_host_port(target: &str) -> (String, Option) { + match target.rsplit_once(':') { + Some((a, p)) => (a.to_string(), p.parse().ok()), + None => (target.to_string(), Some(9777)), + } +} + +/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the +/// known-hosts store as paired, so a later `--connect` connects silently. Same identity +/// store the streaming path uses (same binary), so pairing here makes the stream work. +/// Prints a one-line `paired : fp=` on success; exits non-zero on failure. +pub fn headless_pair(pin: &str) -> glib::ExitCode { + let Some(target) = arg_value("--connect") else { + eprintln!("--pair requires --connect host[:port]"); + return glib::ExitCode::FAILURE; + }; + let (addr, port) = parse_host_port(&target); + let port = port.unwrap_or(9777); + // The label the HOST stores this client under (its paired-devices list). + let name = arg_value("--name").unwrap_or_else(|| "Steam Deck".to_string()); + + let identity = match crate::trust::load_or_create_identity() { + Ok(i) => i, + Err(e) => { + eprintln!("client identity: {e:#}"); + return glib::ExitCode::FAILURE; + } + }; + match crate::trust::pair_with_host(&addr, port, &identity, pin, &name) { + Ok(fp) => { + let fp_hex = crate::trust::hex(&fp); + crate::trust::persist_host( + &arg_value("--host-label").unwrap_or_else(|| addr.clone()), + &addr, + port, + &fp_hex, + true, + ); + println!("paired {addr}:{port} fp={fp_hex}"); + glib::ExitCode::SUCCESS + } + Err(e) => { + eprintln!("pairing failed: {e:?} (wrong PIN, or pairing not armed on the host?)"); + glib::ExitCode::FAILURE + } + } +} + +/// `--connect host[:port]` — skip the hosts page and start a session immediately +/// (scripting + headless testing). Trust follows the same rules as a manual entry: a host +/// 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). +pub fn cli_connect_request() -> Option { + let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?; + let (addr, port) = parse_host_port(&target); + Some(ConnectRequest { + name: addr.clone(), + addr, + port: port?, + fp_hex: None, + pair_optional: false, + launch: None, + }) +} + +/// `--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` +/// when given, else the known-hosts store (matched by address), else none (TOFU-accept). +pub fn headless_library(target: &str) -> glib::ExitCode { + let (addr, port) = match target.rsplit_once(':') { + Some((a, p)) if p.parse::().is_ok() => (a.to_string(), p.parse().unwrap()), + _ => (target.to_string(), crate::library::DEFAULT_MGMT_PORT), + }; + let identity = match crate::trust::load_or_create_identity() { + Ok(i) => i, + Err(e) => { + eprintln!("client identity: {e:#}"); + return glib::ExitCode::FAILURE; + } + }; + let pin = arg_value("--fp") + .as_deref() + .and_then(crate::trust::parse_hex32) + .or_else(|| { + crate::trust::KnownHosts::load() + .hosts + .iter() + .find(|h| h.addr == addr) + .and_then(|h| crate::trust::parse_hex32(&h.fp_hex)) + }); + match crate::library::fetch_games(&addr, port, &identity, pin) { + Ok(games) => { + for g in &games { + println!("{}\t{}\t{}", g.id, g.store, g.title); + } + println!("{} game(s)", games.len()); + glib::ExitCode::SUCCESS + } + Err(e) => { + eprintln!("library: {e}"); + glib::ExitCode::FAILURE + } + } +} + +/// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots. +pub fn shot_scene() -> Option { + std::env::var("PUNKTFUNK_SHOT_SCENE") + .ok() + .filter(|s| !s.is_empty()) +} + +/// Render one mock-populated, host-free scene over the already-presented window, then print +/// `PF_SHOT_READY` once it has had a moment to map + settle so the driver knows when to capture. +/// When `PUNKTFUNK_SHOT_OUT=/path.png` is set the app CAPTURES ITSELF first (widget snapshot → +/// gsk render → PNG, see `save_png`) — no Xvfb/ImageMagick needed, and libadwaita dialogs are +/// in-window overlays so they land in the frame. No `NativeClient` or session is created. The +/// stream scene is deliberately absent — its page requires a live connector (`ui_stream::new` +/// takes an `Arc`). +pub fn run_shot(app: Rc, scene: &str) { + // A plausible host for the trust/pair dialogs (fp_hex is 64 hex chars, like a real SHA-256). + let mock_req = || ConnectRequest { + name: "Living Room PC".to_string(), + addr: "192.168.1.42".to_string(), + port: 9777, + fp_hex: Some( + "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00".to_string(), + ), + pair_optional: true, + launch: None, + }; + let mock_advert = + |key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost { + key: key.to_string(), + fullname: format!("{key}._punktfunk._udp.local."), + name: name.to_string(), + addr: addr.to_string(), + port: 9777, + fp_hex: fp.to_string(), + pair: "required".to_string(), + mgmt_port: None, + }; + + // What the self-capture renders: the main window, except for scenes that open their + // own toplevel (the shortcuts window). + let mut target: gtk::Widget = app.window.clone().upcast(); + match scene { + // The saved-hosts grid reads ~/.config/punktfunk/client-known-hosts.json, which the + // driver seeds. On top, inject synthetic adverts through the same path the mDNS + // stream feeds: one matching the seeded saved host (ONLINE pip + dedup out of the + // discovered grid) and one unknown pair=required host (PIN pill). + "hosts" | "02-hosts" => { + if let Some(h) = app.hosts_ui() { + h.inject_advert(mock_advert( + "mock-online", + "Living Room PC", + "192.168.1.42", + "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00", + )); + h.inject_advert(mock_advert( + "mock-new", + "steamdeck", + "192.168.1.77", + "00aabbccddeeff112233445566778899a0b1c2d3e4f5061728394a5b6c7d8e9f", + )); + } + } + "settings" | "03-settings" => { + crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad, || {}); + } + "trust" | "04-trust" => crate::ui_trust::tofu_dialog(app.clone(), mock_req()), + "pair" | "05-pair" => crate::ui_trust::pin_dialog(app.clone(), mock_req()), + "addhost" | "06-addhost" => { + if let Some(h) = app.hosts_ui() { + h.show_add_host(); + } + } + "shortcuts" | "07-shortcuts" => { + let w = crate::app::shortcuts_window(&app.window); + w.present(); + target = w.upcast(); + } + // The library page with injected entries: mixed stores exercising the badge set, + // 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), + )]; + crate::ui_library::open_mock(app.clone(), mock_req(), games, art); + } + other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"), + } + + let settle_ms = std::env::var("PUNKTFUNK_SHOT_SETTLE_MS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(900); + let scene = scene.to_string(); + glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || { + use std::io::Write as _; + let self_capture = std::env::var("PUNKTFUNK_SHOT_OUT") + .ok() + .filter(|p| !p.is_empty()); + if let Some(out) = &self_capture { + if let Err(e) = save_png(&target, out) { + eprintln!("PF_SHOT_ERROR scene={scene}: {e:#}"); + } + } + println!("PF_SHOT_READY scene={scene}"); + let _ = std::io::stdout().flush(); + // Self-capture mode: the shot is on disk — exit so back-to-back scene runs don't + // stack windows on a live desktop. (The X11-fallback driver captures externally + // after READY and kills us itself.) + if self_capture.is_some() { + std::process::exit(0); + } + }); +} + +/// 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); + gtk::gdk::MemoryTexture::new( + w, + h, + gtk::gdk::MemoryFormat::R8g8b8a8, + &glib::Bytes::from_owned(px), + (w * 4) as usize, + ) + .upcast() +} + +/// Snapshot `widget` (the whole window, dialogs included) into a PNG: WidgetPaintable → +/// `gtk::Snapshot` → the realized native's gsk renderer → `GdkTexture::save_to_png`. +fn save_png(widget: >k::Widget, path: &str) -> anyhow::Result<()> { + use anyhow::Context as _; + use gtk::prelude::*; + let (w, h) = (widget.width(), widget.height()); + anyhow::ensure!(w > 0 && h > 0, "widget not laid out yet ({w}x{h})"); + let paintable = gtk::WidgetPaintable::new(Some(widget)); + let snapshot = gtk::Snapshot::new(); + paintable.snapshot(&snapshot, f64::from(w), f64::from(h)); + let node = snapshot.to_node().context("empty snapshot")?; + let renderer = widget + .native() + .context("widget not realized")? + .renderer() + .context("no gsk renderer")?; + let texture = renderer.render_texture(node, None); + texture + .save_to_png(path) + .with_context(|| format!("save {path}"))?; + Ok(()) +} diff --git a/clients/linux/src/discovery.rs b/clients/linux/src/discovery.rs index 1fcc8cf..8cbd5dd 100644 --- a/clients/linux/src/discovery.rs +++ b/clients/linux/src/discovery.rs @@ -1,6 +1,7 @@ //! LAN host discovery: browse the host's mDNS advert (`_punktfunk._udp`, TXT keys //! `fp`/`pair`/`id` — see the host crate's `discovery.rs`) on a worker thread and stream -//! results to the UI. +//! results to the UI. Removal events are forwarded too, so the hosts page can drop stale +//! cards and flip a saved host's online pip when its advert disappears. use mdns_sd::{ServiceDaemon, ServiceEvent}; @@ -8,6 +9,8 @@ use mdns_sd::{ServiceDaemon, ServiceEvent}; pub struct DiscoveredHost { /// Stable row key: the advertised host id, falling back to the mDNS fullname. pub key: String, + /// The mDNS service fullname — what a later `Removed` event identifies the advert by. + pub fullname: String, pub name: String, pub addr: String, pub port: u16, @@ -15,11 +18,23 @@ pub struct DiscoveredHost { pub fp_hex: String, /// Pairing requirement: `"required"` or `"optional"`. pub pair: String, + /// The management API's port (mDNS `mgmt` TXT) — where the game library is served. + /// `None` when not advertised (older host / standalone `punktfunk1-host`); the + /// library client then falls back to the well-known default. + pub mgmt_port: Option, +} + +/// One discovery update for the UI's advert map. +pub enum DiscoveryEvent { + /// A host advert appeared or refreshed (new address, pairing flipped, …). + Resolved(DiscoveredHost), + /// The advert went away (host stopped / left the network). + Removed { fullname: String }, } /// Browse continuously for the app's lifetime. The thread exits when the receiver is /// dropped (the send fails) or the daemon dies. -pub fn browse() -> async_channel::Receiver { +pub fn browse() -> async_channel::Receiver { let (tx, rx) = async_channel::unbounded(); std::thread::Builder::new() .name("punktfunk-mdns".into()) @@ -39,34 +54,42 @@ pub fn browse() -> async_channel::Receiver { } }; while let Ok(event) = receiver.recv() { - if let ServiceEvent::ServiceResolved(info) = event { - let props = info.get_properties(); - let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string(); - let Some(addr) = info.get_addresses().iter().next().map(|a| a.to_string()) - else { - continue; - }; - let id = val("id"); - let host = DiscoveredHost { - key: if id.is_empty() { - info.get_fullname().to_string() - } else { - id - }, - name: info - .get_fullname() - .split('.') - .next() - .unwrap_or("?") - .to_string(), - addr, - port: info.get_port(), - fp_hex: val("fp"), - pair: val("pair"), - }; - if tx.send_blocking(host).is_err() { - break; // UI gone — stop browsing + let update = match event { + ServiceEvent::ServiceResolved(info) => { + let props = info.get_properties(); + let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string(); + let Some(addr) = info.get_addresses().iter().next().map(|a| a.to_string()) + else { + continue; + }; + let id = val("id"); + DiscoveryEvent::Resolved(DiscoveredHost { + key: if id.is_empty() { + info.get_fullname().to_string() + } else { + id + }, + fullname: info.get_fullname().to_string(), + name: info + .get_fullname() + .split('.') + .next() + .unwrap_or("?") + .to_string(), + addr, + port: info.get_port(), + fp_hex: val("fp"), + pair: val("pair"), + mgmt_port: val("mgmt").parse().ok(), + }) } + ServiceEvent::ServiceRemoved(_ty, fullname) => { + DiscoveryEvent::Removed { fullname } + } + _ => continue, + }; + if tx.send_blocking(update).is_err() { + break; // UI gone — stop browsing } } let _ = daemon.shutdown(); diff --git a/clients/linux/src/gamepad.rs b/clients/linux/src/gamepad.rs index f25ab81..e2af177 100644 --- a/clients/linux/src/gamepad.rs +++ b/clients/linux/src/gamepad.rs @@ -236,7 +236,6 @@ fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) { /// host parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim. /// Enable bits select only the fields each update touches, so rumble (driven separately /// through SDL) and untouched fields keep their state. -#[derive(Default)] struct Ds5Feedback; impl Ds5Feedback { @@ -275,8 +274,12 @@ impl Ds5Feedback { } } -struct Worker { +struct Worker<'a> { subsystem: sdl3::GamepadSubsystem, + /// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin. + pads_out: &'a Mutex>, + active_out: &'a Mutex>, + pinned_out: &'a Mutex>, opened: HashMap, /// Connection order; the most recently connected is the auto selection. order: Vec, @@ -303,7 +306,7 @@ struct Worker { disconnect_fired: bool, } -impl Worker { +impl Worker<'_> { fn active_id(&self) -> Option { self.pinned .filter(|id| self.opened.contains_key(id)) @@ -489,9 +492,245 @@ impl Worker { self.held_touches.remove(&(surface, finger)); } } + + /// Publish the pad list, active pad, and pin to the UI-facing mutexes. + fn publish(&self) { + let mut list: Vec = self + .order + .iter() + .filter_map(|&id| self.pad_info(id)) + .collect(); + list.reverse(); // most recent first — the Settings list order + *self.pads_out.lock().unwrap() = list; + *self.active_out.lock().unwrap() = self.active_id().and_then(|id| self.pad_info(id)); + *self.pinned_out.lock().unwrap() = self.pinned; + } + + /// Apply queued control-plane messages from the UI thread. Returns false when the + /// app side is gone and the worker should exit. + fn drain_ctl(&mut self, ctl: &Receiver) -> bool { + loop { + match ctl.try_recv() { + Ok(Ctl::Attach(c)) => { + self.attached = Some(c); + self.last_axis = [i32::MIN; 6]; + self.reset_chord(); // every session starts un-latched (Attach doesn't flush) + self.set_sensors(true); + } + Ok(Ctl::Detach) => { + self.flush_held(); + self.set_sensors(false); + self.attached = None; + } + Ok(Ctl::Pin(id)) => { + let before = self.active_id(); + self.pinned = id; + if self.active_id() != before { + self.flush_held(); + if self.attached.is_some() { + self.set_sensors(true); + } + } + self.publish(); + } + Err(std::sync::mpsc::TryRecvError::Empty) => return true, + Err(std::sync::mpsc::TryRecvError::Disconnected) => return false, // app gone + } + } + } + + /// Route one SDL event: pad hotplug bookkeeping, and — while a session is attached — + /// buttons/axes/touchpads/motion of the active pad onto the wire. + fn handle_event(&mut self, event: sdl3::event::Event) { + use sdl3::event::Event; + let active = self.active_id(); + match event { + Event::ControllerDeviceAdded { which, .. } => { + if !self.opened.contains_key(&which) { + match self + .subsystem + .open(sdl3::sys::joystick::SDL_JoystickID(which)) + { + Ok(pad) => { + tracing::info!( + name = pad.name().unwrap_or_default(), + "gamepad attached" + ); + self.opened.insert(which, pad); + self.order.push(which); + if self.attached.is_some() && self.active_id() == Some(which) { + self.set_sensors(true); + } + self.publish(); + } + Err(e) => tracing::warn!(error = %e, "gamepad open failed"), + } + } + } + Event::ControllerDeviceRemoved { which, .. } => { + if self.opened.remove(&which).is_some() { + self.order.retain(|&id| id != which); + if active == Some(which) { + self.flush_held(); + } + tracing::info!("gamepad detached"); + self.publish(); + } + } + Event::ControllerButtonDown { which, button, .. } if active == Some(which) => { + let Some(c) = self.attached.clone() else { + return; + }; + if let Some(bit) = button_bit(button) { + self.held_buttons.push(bit); + send(&c, InputKind::GamepadButton, bit, 1); + self.maybe_fire_escape(); + } + } + Event::ControllerButtonUp { which, button, .. } if active == Some(which) => { + let Some(c) = self.attached.clone() else { + return; + }; + if let Some(bit) = button_bit(button) { + self.held_buttons.retain(|&b| b != bit); + send(&c, InputKind::GamepadButton, bit, 0); + self.rearm_escape(); + } + } + Event::ControllerAxisMotion { + which, axis, value, .. + } if active == Some(which) => { + let Some(c) = self.attached.clone() else { + return; + }; + let (id, v) = axis_value(axis, value); + if self.last_axis[id as usize] != v { + self.last_axis[id as usize] = v; + send(&c, InputKind::GamepadAxis, id, v); + } + } + // Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy + // `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface. + Event::ControllerTouchpadDown { + which, + touchpad, + finger, + x, + y, + .. + } + | Event::ControllerTouchpadMotion { + which, + touchpad, + finger, + x, + y, + .. + } if active == Some(which) && self.attached.is_some() => { + self.forward_touch(which, touchpad as u32, finger as u8, x, y, true); + } + Event::ControllerTouchpadUp { + which, + touchpad, + finger, + x, + y, + .. + } if active == Some(which) && self.attached.is_some() => { + self.forward_touch(which, touchpad as u32, finger as u8, x, y, false); + } + // Motion: accel events update the cache; each gyro event ships a sample + // (the DualSense reports both at ~250 Hz). Scale convention shared with + // the Swift client — sign/scale derived, not yet live-verified. + Event::ControllerSensorUpdated { + which, + sensor, + data, + .. + } if active == Some(which) => { + let Some(c) = self.attached.clone() else { + return; + }; + use sdl3::sensor::SensorType; + match sensor { + SensorType::Accelerometer => { + for (i, v) in data.iter().enumerate() { + self.last_accel[i] = + (v / G * ACCEL_LSB_PER_G).clamp(-32768.0, 32767.0) as i16; + } + } + SensorType::Gyroscope => { + let mut gyro = [0i16; 3]; + for (i, v) in data.iter().enumerate() { + gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16; + } + let _ = c.send_rich_input(RichInput::Motion { + pad: 0, + gyro, + accel: self.last_accel, + }); + } + _ => {} + } + } + _ => {} + } + } + + /// 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 + /// refresh-on-update is safe — a dropped stop heals within ~500 ms. + fn render_feedback(&mut self) { + let Some(connector) = self.attached.clone() else { + return; + }; + while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) { + if pad == 0 { + if let Some(p) = self.active_id().and_then(|id| self.opened.get_mut(&id)) { + // Surface a failed SDL rumble write: a swallowed error here (DualSense not in + // the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The + // host logs the send side on 0xCA, so the two together pinpoint host-game vs + // client-render. + if let Err(e) = p.set_rumble(low, high, 5_000) { + tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed"); + } else { + tracing::debug!(low, high, "rumble: rendered"); + } + } else { + tracing::debug!(low, high, "rumble: received but no active pad to render"); + } + } + } + while let Ok(hid) = connector.next_hidout(Duration::ZERO) { + let Some(id) = self.active_id() else { continue }; + let is_ds = self.pad_info(id).is_some_and(|p| p.is_dualsense()); + let Some(pad) = self.opened.get_mut(&id) else { + continue; + }; + match hid { + HidOutput::Led { pad: 0, r, g, b } if is_ds => { + let _ = pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b)); + } + HidOutput::Led { pad: 0, r, g, b } => { + let _ = pad.set_led(r, g, b); + } + HidOutput::PlayerLeds { pad: 0, bits } if is_ds => { + let _ = pad.send_effect(&Ds5Feedback::player_packet(bits)); + } + HidOutput::Trigger { + pad: 0, + which, + ref effect, + } if is_ds => { + let _ = pad.send_effect(&Ds5Feedback::trigger_packet(which, effect)); + } + _ => {} + } + } + } } -#[allow(clippy::too_many_lines)] fn run( pads_out: &Mutex>, active_out: &Mutex>, @@ -516,6 +755,9 @@ fn run( let mut w = Worker { subsystem, + pads_out, + active_out, + pinned_out, opened: HashMap::new(), order: Vec::new(), pinned: None, @@ -531,181 +773,25 @@ fn run( disconnect_fired: false, }; - let publish = |w: &Worker| { - let mut list: Vec = w.order.iter().filter_map(|&id| w.pad_info(id)).collect(); - list.reverse(); // most recent first — the Settings list order - *pads_out.lock().unwrap() = list; - *active_out.lock().unwrap() = w.active_id().and_then(|id| w.pad_info(id)); - *pinned_out.lock().unwrap() = w.pinned; - }; - loop { // Control plane from the UI thread. - loop { - match ctl.try_recv() { - Ok(Ctl::Attach(c)) => { - w.attached = Some(c); - w.last_axis = [i32::MIN; 6]; - w.reset_chord(); // every session starts un-latched (Attach doesn't flush) - w.set_sensors(true); - } - Ok(Ctl::Detach) => { - w.flush_held(); - w.set_sensors(false); - w.attached = None; - } - Ok(Ctl::Pin(id)) => { - let before = w.active_id(); - w.pinned = id; - if w.active_id() != before { - w.flush_held(); - if w.attached.is_some() { - w.set_sensors(true); - } - } - publish(&w); - } - Err(std::sync::mpsc::TryRecvError::Empty) => break, - Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(()), // app gone - } + if !w.drain_ctl(ctl) { + return Ok(()); } - while let Some(event) = pump.poll_event() { - use sdl3::event::Event; - let active = w.active_id(); - match event { - Event::ControllerDeviceAdded { which, .. } => { - if !w.opened.contains_key(&which) { - match w.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(which)) { - Ok(pad) => { - tracing::info!( - name = pad.name().unwrap_or_default(), - "gamepad attached" - ); - w.opened.insert(which, pad); - w.order.push(which); - if w.attached.is_some() && w.active_id() == Some(which) { - w.set_sensors(true); - } - publish(&w); - } - Err(e) => tracing::warn!(error = %e, "gamepad open failed"), - } - } - } - Event::ControllerDeviceRemoved { which, .. } => { - if w.opened.remove(&which).is_some() { - w.order.retain(|&id| id != which); - if active == Some(which) { - w.flush_held(); - } - tracing::info!("gamepad detached"); - publish(&w); - } - } - Event::ControllerButtonDown { which, button, .. } - if active == Some(which) && w.attached.is_some() => - { - if let Some(bit) = button_bit(button) { - w.held_buttons.push(bit); - send( - w.attached.as_ref().unwrap(), - InputKind::GamepadButton, - bit, - 1, - ); - w.maybe_fire_escape(); - } - } - Event::ControllerButtonUp { which, button, .. } - if active == Some(which) && w.attached.is_some() => - { - if let Some(bit) = button_bit(button) { - w.held_buttons.retain(|&b| b != bit); - send( - w.attached.as_ref().unwrap(), - InputKind::GamepadButton, - bit, - 0, - ); - w.rearm_escape(); - } - } - Event::ControllerAxisMotion { - which, axis, value, .. - } if active == Some(which) && w.attached.is_some() => { - let (id, v) = axis_value(axis, value); - if w.last_axis[id as usize] != v { - w.last_axis[id as usize] = v; - send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v); - } - } - // Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy - // `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface. - Event::ControllerTouchpadDown { - which, - touchpad, - finger, - x, - y, - .. - } - | Event::ControllerTouchpadMotion { - which, - touchpad, - finger, - x, - y, - .. - } if active == Some(which) && w.attached.is_some() => { - w.forward_touch(which, touchpad as u32, finger as u8, x, y, true); - } - Event::ControllerTouchpadUp { - which, - touchpad, - finger, - x, - y, - .. - } if active == Some(which) && w.attached.is_some() => { - w.forward_touch(which, touchpad as u32, finger as u8, x, y, false); - } - // Motion: accel events update the cache; each gyro event ships a sample - // (the DualSense reports both at ~250 Hz). Scale convention shared with - // the Swift client — sign/scale derived, not yet live-verified. - Event::ControllerSensorUpdated { - which, - sensor, - data, - .. - } if active == Some(which) && w.attached.is_some() => { - use sdl3::sensor::SensorType; - match sensor { - SensorType::Accelerometer => { - for (i, v) in data.iter().enumerate() { - w.last_accel[i] = - (v / G * ACCEL_LSB_PER_G).clamp(-32768.0, 32767.0) as i16; - } - } - SensorType::Gyroscope => { - let mut gyro = [0i16; 3]; - for (i, v) in data.iter().enumerate() { - gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16; - } - let _ = - w.attached - .as_ref() - .unwrap() - .send_rich_input(RichInput::Motion { - pad: 0, - gyro, - accel: w.last_accel, - }); - } - _ => {} - } - } - _ => {} + // Block in SDL's own event wait instead of a fixed-interval sleep+poll: input + // events are handled the moment they arrive (the old 2 ms sleep added up to 2 ms + // per event), while the timeout bounds the polled work below — ctl messages, + // 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 }); + if let Some(event) = pump.wait_event_timeout(timeout) { + w.handle_event(event); + // Drain whatever else queued while we were waiting or handling. + while let Some(event) = pump.poll_event() { + w.handle_event(event); } } @@ -713,59 +799,6 @@ fn run( // new button events; the chord itself is only detected while a session is attached). w.maybe_fire_disconnect(); - // Feedback planes (this thread is their single consumer). The host re-sends - // rumble state periodically, so a generous duration with refresh-on-update is - // safe — a dropped stop heals within ~500 ms. - if let Some(connector) = w.attached.clone() { - while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) { - if pad == 0 { - if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) { - // Surface a failed SDL rumble write: a swallowed error here (DualSense not in - // the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The - // host logs the send side on 0xCA, so the two together pinpoint host-game vs - // client-render. - if let Err(e) = p.set_rumble(low, high, 5_000) { - tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed"); - } else { - tracing::debug!(low, high, "rumble: rendered"); - } - } else { - tracing::debug!(low, high, "rumble: received but no active pad to render"); - } - } - } - while let Ok(hid) = connector.next_hidout(Duration::ZERO) { - let Some(id) = w.active_id() else { continue }; - let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense()); - let Some(pad) = w.opened.get_mut(&id) else { - continue; - }; - match hid { - HidOutput::Led { pad: 0, r, g, b } if is_ds => { - let _ = pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b)); - } - HidOutput::Led { pad: 0, r, g, b } => { - let _ = pad.set_led(r, g, b); - } - HidOutput::PlayerLeds { pad: 0, bits } if is_ds => { - let _ = pad.send_effect(&Ds5Feedback::player_packet(bits)); - } - HidOutput::Trigger { - pad: 0, - which, - ref effect, - } if is_ds => { - let _ = pad.send_effect(&Ds5Feedback::trigger_packet(which, effect)); - } - _ => {} - } - } - } - - std::thread::sleep(Duration::from_millis(if w.attached.is_some() { - 2 - } else { - 30 - })); + w.render_feedback(); } } diff --git a/clients/linux/src/launch.rs b/clients/linux/src/launch.rs new file mode 100644 index 0000000..cc0e8f8 --- /dev/null +++ b/clients/linux/src/launch.rs @@ -0,0 +1,320 @@ +//! Session launch: resolve the stream mode, spawn the session worker, and drive its +//! event stream into the UI (trust persistence, stream-page push, teardown). + +use crate::app::App; +use crate::session::{SessionEvent, SessionParams, Stats}; +use crate::trust; +use crate::ui_hosts::ConnectRequest; +use crate::video::DecodedFrame; +use adw::prelude::*; +use gtk::{gdk, glib}; +use punktfunk_core::client::NativeClient; +use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; +use std::rc::Rc; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +/// The mode to request: explicit settings, with `0` fields resolved to the native +/// size/refresh of the monitor the window currently occupies (mirrors the Swift client's +/// native-display default). +fn resolve_mode(app: &App) -> Mode { + let s = app.settings.borrow(); + let mut mode = Mode { + width: s.width, + height: s.height, + refresh_hz: s.refresh_hz, + }; + if mode.width == 0 || mode.refresh_hz == 0 { + // Prefer the monitor the window is on; fall back to the display's first monitor. On a + // `--connect` launch the window may not be mapped yet when this runs, and without the + // fallback we'd drop to the 1920×1080 floor below — wrong on the Deck (1280×800). + let monitor = app + .window + .surface() + .zip(gdk::Display::default()) + .and_then(|(surf, d)| d.monitor_at_surface(&surf)) + .or_else(|| { + gdk::Display::default() + .and_then(|d| d.monitors().item(0)) + .and_then(|o| o.downcast::().ok()) + }); + if let Some(m) = monitor { + let geo = m.geometry(); + let scale = m.scale_factor().max(1); + if mode.width == 0 { + mode.width = (geo.width() * scale) as u32; + mode.height = (geo.height() * scale) as u32; + } + if mode.refresh_hz == 0 { + mode.refresh_hz = ((m.refresh_rate() + 500) / 1000).max(30) as u32; + } + } + } + // No monitor info (early call, odd compositor) — a sane floor. + if mode.width == 0 { + (mode.width, mode.height) = (1920, 1080); + } + if mode.refresh_hz == 0 { + mode.refresh_hz = 60; + } + mode +} + +/// Tunables for a session start that differ between the normal connect and the "request access" +/// (delegated-approval) flow. `Default` is the normal connect. +pub struct StartOpts { + /// Handshake budget. The request-access flow uses a long one because the host PARKS the + /// connection until the operator clicks Approve (see the host's `PENDING_APPROVAL_WAIT`). + pub connect_timeout: std::time::Duration, + /// Persist the host as *paired* on a successful connect. Set for request-access, where the + /// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU + /// persists the host *unpaired* (pinned, but not PIN/approval-verified). + pub persist_paired: bool, + /// A "waiting for approval" dialog to dismiss on the first session event (request-access only). + pub waiting: Option, + /// Set by the waiting dialog's Cancel button. `NativeClient::connect` is a blocking call with + /// no abort, so Cancel returns the UI immediately (clears busy, closes the dialog) and leaves + /// the in-flight connect to time out; when it finally resolves, the event loop sees this flag + /// and tears down silently (drops the connector → closes the connection) without touching the + /// UI a new session may already own. + pub cancel: Option>>, +} + +impl Default for StartOpts { + fn default() -> Self { + Self { + connect_timeout: std::time::Duration::from_secs(15), + persist_paired: false, + waiting: None, + cancel: None, + } + } +} + +pub fn start_session(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>) { + start_session_with(app, req, pin, StartOpts::default()); +} + +pub fn start_session_with( + app: Rc, + req: ConnectRequest, + pin: Option<[u8; 32]>, + opts: StartOpts, +) { + if app.busy.replace(true) { + return; + } + let mode = resolve_mode(&app); + let s = app.settings.borrow(); + let params = SessionParams { + host: req.addr.clone(), + port: req.port, + mode, + compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto), + // "Automatic" matches the physical pad (Swift parity); an explicit choice wins. + gamepad: match GamepadPref::from_name(&s.gamepad) { + Some(GamepadPref::Auto) | None => app.gamepad.auto_pref(), + Some(explicit) => explicit, + }, + bitrate_kbps: s.bitrate_kbps, + mic_enabled: s.mic_enabled, + audio_channels: s.audio_channels, + preferred_codec: s.preferred_codec(), + decoder: s.decoder.clone(), + launch: req.launch.as_ref().map(|(id, _)| id.clone()), + pin, + identity: app.identity.clone(), + connect_timeout: opts.connect_timeout, + }; + let inhibit = s.inhibit_shortcuts; + let show_stats = s.show_stats; + drop(s); + let cancel = opts.cancel; + + // Card feedback while the connect is in flight: spinner on the matching hosts card, + // stale failure banner dismissed. Cleared again on Connected/Failed/Ended. + if let Some(h) = app.hosts_ui() { + h.clear_error(); + h.set_connecting(Some(req.card_key())); + } + + let mut handle = crate::session::start(params); + let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1); + let mut ctx = SessionUi { + stop: handle.stop.clone(), + app, + req, + persist_paired: opts.persist_paired, + tofu: pin.is_none(), + inhibit, + show_stats, + frames: Some(frames), + waiting: opts.waiting, + page: None, + }; + glib::spawn_future_local(async move { + while let Ok(event) = handle.events.recv().await { + // A cancelled request-access connect resolved late: tear down silently. Don't touch + // app.busy — Cancel already cleared it, and a fresh session may now own it. + if cancel.as_ref().is_some_and(|c| c.get()) { + ctx.close_waiting(); + break; + } + match event { + SessionEvent::Connected { + connector, + mode, + fingerprint, + } => ctx.on_connected(connector, mode, fingerprint), + SessionEvent::Stats(s) => ctx.on_stats(s), + SessionEvent::Failed { + msg, + trust_rejected, + } => { + ctx.on_failed(&msg, trust_rejected); + break; + } + SessionEvent::Ended(err) => { + ctx.on_ended(err); + break; + } + } + } + }); +} + +/// UI-side state one session's event loop carries between events. +struct SessionUi { + app: Rc, + req: ConnectRequest, + /// Persist the host as PAIRED on `Connected` (request-access — the approval IS the pairing). + persist_paired: bool, + /// This is a TOFU connect (no stored pin): pin the observed fingerprint on `Connected`. + tofu: bool, + /// Grab compositor shortcuts while input is captured (Settings). + inhibit: bool, + /// Show the stats OSD when the stream page opens (Settings; live-toggled on-page). + show_stats: bool, + stop: Arc, + /// Decoded-frame receiver, handed to the stream page once on `Connected`. + frames: Option>, + /// The "waiting for approval" dialog (request-access flow), dismissed on the first event. + waiting: Option, + page: Option, +} + +impl SessionUi { + /// Dismiss the "waiting for approval" dialog (request-access flow), if any. + fn close_waiting(&mut self) { + if let Some(w) = self.waiting.take() { + w.close(); + } + } + + /// `Connected`: record the configured trust decision, attach gamepads, and push the + /// stream page. + fn on_connected(&mut self, connector: Arc, mode: Mode, fingerprint: [u8; 32]) { + self.close_waiting(); + if let Some(h) = self.app.hosts_ui() { + h.set_connecting(None); + } + if self.persist_paired { + // Request-access: the operator approved this device, so record the host as + // a trusted PAIRED host (pinning the fingerprint we observed) — future + // connects are then silent (rule 1), exactly like after a PIN ceremony. + let fp_hex = trust::hex(&fingerprint); + trust::persist_host(&self.req.name, &self.req.addr, self.req.port, &fp_hex, true); + self.app.toast("Approved — connecting…"); + } else if self.tofu { + // A TOFU connect just observed the real fingerprint — pin it from now on. + let fp_hex = trust::hex(&fingerprint); + trust::persist_host( + &self.req.name, + &self.req.addr, + self.req.port, + &fp_hex, + false, + ); + self.app.toast(&format!( + "Trusted on first use — fingerprint {}…", + &fp_hex[..16] + )); + } + // Stamp the successful connect — this host's card carries the accent bar now. + trust::touch_last_used(&trust::hex(&fingerprint)); + tracing::debug!(?mode, "connected — pushing stream page"); + // A library launch titles the stream with the game, not the host. + let name = self + .req + .launch + .as_ref() + .map_or(self.req.name.as_str(), |(_, game)| game.as_str()); + let title = format!( + "{name} · {}×{}@{}", + mode.width, mode.height, mode.refresh_hz + ); + self.app.gamepad.attach(connector.clone()); + let clock_offset_ns = connector.clock_offset_ns; + let p = crate::ui_stream::new(crate::ui_stream::StreamPageArgs { + window: self.app.window.clone(), + connector, + frames: self.frames.take().expect("Connected delivered once"), + clock_offset_ns, + escape_rx: self.app.gamepad.escape_events(), + disconnect_rx: self.app.gamepad.disconnect_events(), + stop: self.stop.clone(), + inhibit_shortcuts: self.inhibit, + show_stats: self.show_stats, + title, + }); + self.app.nav.push(&p.page); + // Steam Deck / Gaming Mode: gamescope fullscreens the window but GTK doesn't + // know it, so its header bar stays drawn. Enter GTK fullscreen explicitly — + // the stream page's `connect_fullscreened_notify` then hides all chrome. + if self.app.fullscreen { + self.app.window.fullscreen(); + } + self.page = Some(p); + } + + fn on_stats(&self, s: Stats) { + if let Some(p) = &self.page { + p.update_stats(s); + } + } + + /// `Failed`: surface the error; a trust rejection on a pinned connect routes to re-pairing. + fn on_failed(&mut self, msg: &str, trust_rejected: bool) { + self.close_waiting(); + tracing::warn!(%msg, trust_rejected, "connect failed"); + self.app.busy.set(false); + if let Some(h) = self.app.hosts_ui() { + h.set_connecting(None); + } + // 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 { + 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 { + // Errors land on the hosts page banner, 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. + fn on_ended(&mut self, err: Option) { + self.close_waiting(); + self.app.gamepad.detach(); + self.app.nav.pop_to_tag("hosts"); + if let Some(h) = self.app.hosts_ui() { + h.set_connecting(None); + } + if let Some(e) = err { + self.app.connect_error(&e); + } + self.app.busy.set(false); + } +} diff --git a/clients/linux/src/library.rs b/clients/linux/src/library.rs new file mode 100644 index 0000000..f9025d5 --- /dev/null +++ b/clients/linux/src/library.rs @@ -0,0 +1,312 @@ +//! Game-library client for the host's management REST API (the Apple `LibraryClient` +//! ported): `GET https://:/api/v1/library` plus the per-title art proxy. +//! Authentication is **mTLS** — this client presents its persistent identity (the same +//! cert the host paired over QUIC) and the host authorizes paired certificates for the +//! read-only library routes, no bearer token. The host's self-signed certificate is +//! verified by its pinned SHA-256 fingerprint (`KnownHost::fp_hex`), not a CA chain. + +use serde::Deserialize; +use std::io::Read; +use std::sync::Arc; +use std::time::Duration; + +/// The management API's default port — matches `mgmt::DEFAULT_PORT` on the host. A +/// discovered host may override it via its mDNS `mgmt` TXT (`DiscoveredHost::mgmt_port`); +/// saved-but-not-advertising hosts fall back here (Apple parity). +pub const DEFAULT_MGMT_PORT: u16 = 47990; + +/// Cover-art URLs, mirroring the host's `library::Artwork`: absolute CDN URLs for custom +/// entries, host-relative proxy paths (`/api/v1/library/art/...`) for Steam titles. The +/// wire shape also carries a `logo` (a transparent title logo) — not a poster kind, so +/// serde just skips it here. +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Artwork { + #[serde(default)] + pub portrait: Option, + #[serde(default)] + pub hero: Option, + #[serde(default)] + pub header: Option, +} + +impl Artwork { + /// Poster candidates in the Apple client's fallback order — portrait (the 600×900 + /// capsule) → header (near-universal) → hero — with host-relative paths resolved + /// against `base` so the loader only ever sees absolute URLs. + pub fn poster_candidates(&self, base: &str) -> Vec { + [&self.portrait, &self.header, &self.hero] + .into_iter() + .flatten() + .map(|u| { + if u.starts_with('/') { + format!("{base}{u}") + } else { + u.clone() + } + }) + .collect() + } +} + +/// One title in the host's unified library. `id` is store-qualified (`steam:`, +/// `custom:`) and is also the launch handle the Hello carries when a session is +/// started from the library. The host's `launch` spec field is deliberately not +/// deserialized — launching goes by id, the host resolves the spec itself. +#[derive(Clone, Debug, Deserialize)] +pub struct GameEntry { + pub id: String, + /// Which store surfaced it (`"steam"`, `"custom"`, future `"heroic"`/`"gog"`/…) — + /// drives the poster's store badge. + pub store: String, + pub title: String, + #[serde(default)] + pub art: Artwork, +} + +/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet"). +#[derive(Debug)] +pub enum LibraryError { + /// The host rejected our certificate — this device isn't on its paired list. + NotPaired, + /// The host's certificate didn't hash to the pinned fingerprint (impostor/rotated cert). + PinMismatch, + Http(u16), + Unreachable(String), +} + +impl std::fmt::Display for LibraryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LibraryError::NotPaired => f.write_str( + "The host didn't recognize this device. Pair with the host first — the \ + library is authorized by this device's certificate (no token needed).", + ), + LibraryError::PinMismatch => f.write_str( + "The host's certificate doesn't match the pinned fingerprint. \ + Re-pair with a PIN to re-establish trust.", + ), + LibraryError::Http(code) => { + write!(f, "The management API returned HTTP {code}.") + } + LibraryError::Unreachable(why) => write!( + f, + "Couldn't reach the host's management API: {why}. Check the host is \ + updated and reachable (a host pinned to --mgmt-bind 127.0.0.1 is \ + loopback-only and can't be browsed remotely)." + ), + } + } +} + +/// `https://addr:port`, IPv6 literals bracketed. +pub fn base_url(addr: &str, mgmt_port: u16) -> String { + if addr.contains(':') { + format!("https://[{addr}]:{mgmt_port}") + } else { + format!("https://{addr}:{mgmt_port}") + } +} + +/// An HTTPS agent presenting `identity` via TLS client auth and verifying the server by +/// `pin` (`None` = accept any cert, the TOFU special case — same semantics as the QUIC +/// connect). Reused across a whole grid's worth of poster loads. +pub fn agent( + identity: &(String, String), + pin: Option<[u8; 32]>, +) -> Result { + use rustls::pki_types::pem::PemObject; + let bad = + |what: &str, e: &dyn std::fmt::Display| LibraryError::Unreachable(format!("{what}: {e}")); + // The ring provider, explicitly — the same one core's QUIC endpoints install, so the + // process never mixes rustls crypto providers. + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let builder = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|e| bad("tls config", &e))? + .dangerous() + .with_custom_certificate_verifier(Arc::new(PinVerify { pin })); + let cert = rustls::pki_types::CertificateDer::from_pem_slice(identity.0.as_bytes()) + .map_err(|e| bad("client cert pem", &e))?; + let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(identity.1.as_bytes()) + .map_err(|e| bad("client key pem", &e))?; + let cfg = builder + .with_client_auth_cert(vec![cert], key) + .map_err(|e| bad("client auth", &e))?; + Ok(ureq::AgentBuilder::new() + .tls_config(Arc::new(cfg)) + .timeout_connect(Duration::from_secs(5)) + .timeout(Duration::from_secs(10)) + .build()) +} + +/// Fetch the host's unified library. Errors are pre-classified for the UI (401/403 → +/// [`LibraryError::NotPaired`], a pin-verifier rejection → [`LibraryError::PinMismatch`]). +pub fn fetch_games( + addr: &str, + mgmt_port: u16, + identity: &(String, String), + pin: Option<[u8; 32]>, +) -> Result, LibraryError> { + let agent = agent(identity, pin)?; + let url = format!("{}/api/v1/library", base_url(addr, mgmt_port)); + let body = match agent.get(&url).call() { + Ok(resp) => resp + .into_string() + .map_err(|e| LibraryError::Unreachable(format!("read body: {e}")))?, + Err(e) => return Err(classify(e)), + }; + serde_json::from_str(&body).map_err(|e| LibraryError::Unreachable(format!("bad JSON: {e}"))) +} + +/// Poster-art byte fetch cap — largest Steam hero assets run a few MB; anything bigger is +/// not an image we want to hand to the texture decoder. +const ART_MAX_BYTES: u64 = 16 * 1024 * 1024; + +/// Fetch one cover-art image. URLs on the host itself (under `base`) go through the +/// pinned mTLS agent (the host's art proxy requires the paired cert); any other origin — +/// a public CDN URL on a custom entry — uses ureq's default agent with normal webpki +/// trust and no client cert (Apple's `LibraryTLSDelegate` does the same split). +pub fn fetch_art(pinned: &ureq::Agent, base: &str, url: &str) -> Result, LibraryError> { + let resp = if url.starts_with(base) { + pinned.get(url).call() + } else { + ureq::get(url).timeout(Duration::from_secs(10)).call() + } + .map_err(classify)?; + let mut bytes = Vec::new(); + resp.into_reader() + .take(ART_MAX_BYTES) + .read_to_end(&mut bytes) + .map_err(|e| LibraryError::Unreachable(format!("read image: {e}")))?; + Ok(bytes) +} + +fn classify(e: ureq::Error) -> LibraryError { + match e { + ureq::Error::Status(401 | 403, _) => LibraryError::NotPaired, + ureq::Error::Status(code, _) => LibraryError::Http(code), + ureq::Error::Transport(t) => { + // A pin rejection surfaces as a TLS alert wrapped in a transport error; the + // verifier's error kind survives in the message. + let msg = t.to_string(); + if msg.contains("ApplicationVerificationFailure") || msg.contains("InvalidCertificate") + { + LibraryError::PinMismatch + } else { + LibraryError::Unreachable(msg) + } + } + } +} + +/// Fingerprint-pinning verifier — the client-HTTP twin of core's (private) QUIC +/// `PinVerify`: trust is the SHA-256 of the host's self-signed leaf cert. The handshake +/// signatures MUST still be verified for real: CertificateVerify is what proves the peer +/// *holds the pinned cert's private key* — skip it and an active MITM can replay the +/// host's (public) certificate, match the pin, and complete the handshake with its own key. +#[derive(Debug)] +struct PinVerify { + pin: Option<[u8; 32]>, +} + +impl rustls::client::danger::ServerCertVerifier for PinVerify { + fn verify_server_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + if let Some(expected) = self.pin { + let fp = punktfunk_core::quic::endpoint::cert_fingerprint(end_entity.as_ref()); + if fp != expected { + return Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::ApplicationVerificationFailure, + )); + } + } + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn poster_candidates_order_and_resolution() { + // Fallback order is portrait → header → hero, host-relative paths resolved. + let art = Artwork { + portrait: Some("/api/v1/library/art/steam:570/portrait".into()), + hero: Some("https://cdn.example/hero.jpg".into()), + header: Some("/api/v1/library/art/steam:570/header".into()), + }; + assert_eq!( + art.poster_candidates("https://192.168.1.42:47990"), + vec![ + "https://192.168.1.42:47990/api/v1/library/art/steam:570/portrait", + "https://192.168.1.42:47990/api/v1/library/art/steam:570/header", + "https://cdn.example/hero.jpg", + ] + ); + assert!(Artwork::default() + .poster_candidates("https://h:47990") + .is_empty()); + } + + #[test] + fn game_entry_decodes_the_wire_shape() { + // The exact shape mgmt.rs serializes (optional art fields omitted, launch ignored). + let json = r#"[ + {"id":"steam:570","store":"steam","title":"Dota 2", + "art":{"portrait":"/api/v1/library/art/steam:570/portrait"}, + "launch":{"kind":"steam_appid","value":"570"}}, + {"id":"custom:abc","store":"custom","title":"My Emu","art":{}} + ]"#; + let games: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(games.len(), 2); + assert_eq!(games[0].id, "steam:570"); + assert!(games[1].art.portrait.is_none()); + } + + #[test] + fn ipv6_base_url_is_bracketed() { + assert_eq!(base_url("fe80::1", 47990), "https://[fe80::1]:47990"); + assert_eq!(base_url("192.168.1.42", 1234), "https://192.168.1.42:1234"); + } +} diff --git a/clients/linux/src/main.rs b/clients/linux/src/main.rs index 8688125..798a625 100644 --- a/clients/linux/src/main.rs +++ b/clients/linux/src/main.rs @@ -10,22 +10,32 @@ mod app; #[cfg(target_os = "linux")] mod audio; #[cfg(target_os = "linux")] +mod cli; +#[cfg(target_os = "linux")] mod discovery; #[cfg(target_os = "linux")] mod gamepad; #[cfg(target_os = "linux")] mod keymap; #[cfg(target_os = "linux")] +mod launch; +#[cfg(target_os = "linux")] +mod library; +#[cfg(target_os = "linux")] mod session; #[cfg(target_os = "linux")] mod trust; #[cfg(target_os = "linux")] mod ui_hosts; #[cfg(target_os = "linux")] +mod ui_library; +#[cfg(target_os = "linux")] mod ui_settings; #[cfg(target_os = "linux")] mod ui_stream; #[cfg(target_os = "linux")] +mod ui_trust; +#[cfg(target_os = "linux")] mod video; #[cfg(target_os = "linux")] diff --git a/clients/linux/src/session.rs b/clients/linux/src/session.rs index c2c6276..261f427 100644 --- a/clients/linux/src/session.rs +++ b/clients/linux/src/session.rs @@ -1,11 +1,13 @@ -//! Session controller: one worker thread runs connect → pump (video pull + decode, audio -//! pull + Opus decode, stats), feeding the GTK main loop over channels. The UI keeps the -//! `Arc` from the `Connected` event for direct input sends (no extra hop on -//! the input path) — `NativeClient` is `Sync`, planes stay one-consumer-per-thread: -//! video+audio here, rumble+hidout on the gamepad thread. +//! Session controller: the worker thread runs connect → pump (video pull + decode + +//! stats), a dedicated audio thread pulls + Opus-decodes the audio plane (Apple +//! `SessionAudio` parity — audio never waits behind a video decode), both feeding the GTK +//! main loop / PipeWire over channels. The UI keeps the `Arc` from the +//! `Connected` event for direct input sends (no extra hop on the input path) — +//! `NativeClient` is `Sync`, planes stay one-consumer-per-thread: video here, audio on +//! its own thread, rumble+hidout on the gamepad thread. use crate::audio; -use crate::video::{DecodedFrame, Decoder}; +use crate::video::{DecodedFrame, DecodedImage, Decoder}; use punktfunk_core::client::NativeClient; use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; use punktfunk_core::PunktfunkError; @@ -27,6 +29,12 @@ pub struct SessionParams { pub preferred_codec: u8, /// Stream the default microphone to the host's virtual mic source. pub mic_enabled: bool, + /// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see + /// `video::Decoder::new`). + pub decoder: String, + /// Library id for the host to launch this session (`"steam:570"`, from the library + /// page); `None` = plain desktop session. + pub launch: Option, /// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one). pub pin: Option<[u8; 32]>, pub identity: (String, String), @@ -44,6 +52,9 @@ pub struct Stats { pub decode_ms: f32, /// Median capture→decoded latency over the last window (host-clock corrected). pub latency_ms: f32, + /// The decode path frames actually took this window (`"vaapi"`/`"software"`, empty + /// until the first frame) — the OSD's trailing tag; tracks a mid-session fallback. + pub decoder: &'static str, } pub enum SessionEvent { @@ -86,7 +97,7 @@ pub fn start(params: SessionParams) -> SessionHandle { } } -fn now_ns() -> u64 { +pub fn now_ns() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos() as u64) @@ -146,7 +157,7 @@ fn pump( params.audio_channels, crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1) params.preferred_codec, // the user's soft codec preference (0 = auto) - None, // launch: the Linux client has no library picker yet + params.launch.clone(), params.pin, Some(params.identity), params.connect_timeout, @@ -175,14 +186,15 @@ fn pump( fingerprint: connector.host_fingerprint, }); - // Build the decoder for the codec the host resolved (never assume HEVC). + // Build the decoder for the codec the host resolved (never assume HEVC), honoring the + // Settings backend preference (auto/vaapi/software). let codec_id = crate::video::ffmpeg_codec_id(connector.codec); tracing::info!( ?codec_id, welcome_codec = connector.codec, "negotiated video codec" ); - let mut decoder = match Decoder::new(codec_id) { + let mut decoder = match Decoder::new(codec_id, ¶ms.decoder) { Ok(d) => d, Err(e) => { let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}")))); @@ -190,16 +202,9 @@ fn pump( } }; // Audio is best-effort: a session without it still streams. Gamepads are the - // app-lifetime service's job (the UI attaches it on Connected). Build the decoder + playback - // from the host-RESOLVED channel count (never the request), so an older/clamping host that - // resolves stereo is decoded as stereo. - let channels = connector.audio_channels; - let player = audio::AudioPlayer::spawn(channels as u32) - .map_err(|e| tracing::warn!(error = %e, "audio disabled")) - .ok(); - let mut opus_dec = AudioDec::new(channels) - .map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled")) - .ok(); + // app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own + // thread (one puller per plane), blocking on the audio queue like the Apple client. + let audio_thread = spawn_audio(connector.clone(), stop.clone()); let _mic = params .mic_enabled .then(|| { @@ -216,8 +221,10 @@ fn pump( let mut bytes_n = 0u64; let mut decode_us_sum = 0u64; let mut lat_us: Vec = Vec::with_capacity(256); - let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels - // Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs. + // What actually decoded the last frame — a VAAPI failure demotes mid-session, so + // this is read off each frame's image variant rather than fixed at startup. + let mut dec_path: &'static str = ""; + // Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs. let mut last_dropped = connector.frames_dropped(); let mut last_kf_req: Option = None; @@ -225,16 +232,23 @@ fn pump( if stop.load(Ordering::SeqCst) { break None; } - match connector.next_frame(Duration::from_millis(4)) { + // 20 ms wait: audio has its own thread now, so this only bounds stop-flag + // responsiveness and the per-iteration keyframe-recovery check (a frame arrives + // every ~8–16 ms at 60–120 Hz anyway, so this rarely times out mid-stream). + match connector.next_frame(Duration::from_millis(20)) { Ok(frame) => { let t0 = Instant::now(); match decoder.decode(&frame.data) { - Ok(Some(decoded)) => { + Ok(Some(image)) => { total_frames += 1; + dec_path = match &image { + DecodedImage::Cpu(_) => "software", + DecodedImage::Dmabuf(_) => "vaapi", + }; if total_frames == 1 { - let (w, h, path) = match &decoded { - DecodedFrame::Cpu(c) => (c.width, c.height, "software"), - DecodedFrame::Dmabuf(d) => (d.width, d.height, "vaapi-dmabuf"), + let (w, h, path) = match &image { + DecodedImage::Cpu(c) => (c.width, c.height, "software"), + DecodedImage::Dmabuf(d) => (d.width, d.height, "vaapi-dmabuf"), }; tracing::info!(width = w, height = h, path, "first frame decoded"); } @@ -248,7 +262,10 @@ fn pump( decode_us_sum += t0.elapsed().as_micros() as u64; frames_n += 1; bytes_n += frame.data.len() as u64; - let _ = frame_tx.force_send(decoded); + let _ = frame_tx.force_send(DecodedFrame { + pts_ns: frame.pts_ns, + image, + }); } Ok(None) => {} // Survivable (loss until the next IDR/RFI recovery) — keep feeding. @@ -276,17 +293,6 @@ fn pump( } } - // Drain audio between frames (packets land every 5 ms; the queue holds 320 ms). - while let Ok(pkt) = connector.next_audio(Duration::ZERO) { - if let (Some(player), Some(dec)) = (&player, opus_dec.as_mut()) { - match dec.decode_float(&pkt.data, &mut pcm, false) { - // `samples` is per-channel; the interleaved frame is `samples * channels`. - Ok(samples) => player.push(pcm[..samples * channels as usize].to_vec()), - Err(e) => tracing::debug!(error = %e, "opus decode"), - } - } - } - if window_start.elapsed() >= Duration::from_secs(1) { let secs = window_start.elapsed().as_secs_f32(); lat_us.sort_unstable(); @@ -306,6 +312,7 @@ fn pump( 0.0 }, latency_ms: p50 as f32 / 1000.0, + decoder: dec_path, })); window_start = Instant::now(); frames_n = 0; @@ -321,5 +328,52 @@ fn pump( "session ended" ); stop.store(true, Ordering::SeqCst); + if let Some(t) = audio_thread { + let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set + } let _ = ev_tx.send_blocking(SessionEvent::Ended(end)); } + +/// The dedicated audio thread: owns the Opus decoder, the PCM scratch, and the PipeWire +/// player, and blocks on `next_audio` (the plane's single consumer — packets land every +/// 5 ms). Decoded chunks are pushed in Vecs recycled from the player's pool, so the +/// steady state allocates nothing. Best-effort like before: any setup failure logs and +/// the session streams video-only. Exits on the stop flag or a closed plane. +fn spawn_audio( + connector: Arc, + stop: Arc, +) -> Option> { + // Decoder + playback are built from the host-RESOLVED channel count (never the + // request), so an older/clamping host that resolves stereo is decoded as stereo. + let channels = connector.audio_channels; + let player = audio::AudioPlayer::spawn(channels as u32) + .map_err(|e| tracing::warn!(error = %e, "audio disabled")) + .ok()?; + let mut dec = AudioDec::new(channels) + .map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled")) + .ok()?; + std::thread::Builder::new() + .name("punktfunk-audio-rx".into()) + .spawn(move || { + let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels + while !stop.load(Ordering::SeqCst) { + match connector.next_audio(Duration::from_millis(100)) { + Ok(pkt) => match dec.decode_float(&pkt.data, &mut pcm, false) { + // `samples` is per-channel; the interleaved frame is `samples * channels`. + Ok(samples) => { + let n = samples * channels as usize; + let mut buf = player.take_buffer(); + buf.extend_from_slice(&pcm[..n]); + player.push(buf); + } + Err(e) => tracing::debug!(error = %e, "opus decode"), + }, + Err(PunktfunkError::NoFrame) => {} + Err(_) => break, // plane closed — the session is ending + } + } + tracing::debug!("audio pull thread exited"); + }) + .map_err(|e| tracing::warn!(error = %e, "audio thread failed to start — audio disabled")) + .ok() +} diff --git a/clients/linux/src/trust.rs b/clients/linux/src/trust.rs index eb676bc..daa8277 100644 --- a/clients/linux/src/trust.rs +++ b/clients/linux/src/trust.rs @@ -4,6 +4,7 @@ //! so a box pairs once whichever client it uses. use anyhow::{anyhow, Context, Result}; +use punktfunk_core::client::NativeClient; use punktfunk_core::quic::endpoint; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -55,6 +56,10 @@ pub struct KnownHost { pub fp_hex: String, /// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use). pub paired: bool, + /// Unix seconds of the last successful connect — the hosts page marks the + /// most-recent card with the accent bar. `default` so pre-existing stores load. + #[serde(default)] + pub last_used: Option, } #[derive(Default, Serialize, Deserialize)] @@ -106,12 +111,64 @@ impl KnownHosts { h.addr = entry.addr; h.port = entry.port; h.paired |= entry.paired; + // A refresh without a timestamp must not erase the stored one. + if entry.last_used.is_some() { + h.last_used = entry.last_used; + } } else { self.hosts.push(entry); } } } +/// Load-upsert-save in one step — the pin every trust decision (TOFU accept, PIN +/// ceremony, delegated approval, headless pairing) ends in. +pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: bool) { + let mut known = KnownHosts::load(); + known.upsert(KnownHost { + name: name.to_string(), + addr: addr.to_string(), + port, + fp_hex: fp_hex.to_string(), + paired, + last_used: None, + }); + let _ = known.save(); +} + +/// Stamp "now" as this host's last successful connect (drives the hosts page's +/// most-recent accent). No-op when the fingerprint isn't stored. +pub fn touch_last_used(fp_hex: &str) { + let mut known = KnownHosts::load(); + if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp_hex) { + h.last_used = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .ok(); + let _ = known.save(); + } +} + +/// Run the SPAKE2 PIN ceremony against a host. `device_name` is the label the HOST +/// stores this client under (its paired-devices list); the 90 s budget covers a +/// human-typed PIN. Returns the host's now-verified certificate fingerprint to pin. +pub fn pair_with_host( + addr: &str, + port: u16, + identity: &(String, String), + pin: &str, + device_name: &str, +) -> std::result::Result<[u8; 32], punktfunk_core::PunktfunkError> { + NativeClient::pair( + addr, + port, + (&identity.0, &identity.1), + pin.trim(), + device_name, + std::time::Duration::from_secs(90), + ) +} + /// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file /// stays readable; parsed with `*Pref::from_name` at connect time. #[derive(Clone, Serialize, Deserialize)] @@ -139,6 +196,14 @@ pub struct Settings { /// preference — the host honors it when it can emit it, else falls back to the best shared codec. #[serde(default = "default_codec")] pub codec: String, + /// Video decoder preference: `"auto"` (VAAPI → software), `"vaapi"`, `"software"`. + /// The `PUNKTFUNK_DECODER` env var overrides this (see `video::Decoder::new`). + pub decoder: String, + /// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S). + pub show_stats: bool, + /// Experimental: the game-library browser ("Browse library…" on saved cards) — + /// mirrors the Apple client's "Show game library" toggle, default off. + pub library_enabled: bool, } fn default_codec() -> String { @@ -170,6 +235,9 @@ impl Default for Settings { mic_enabled: false, audio_channels: 2, codec: "auto".into(), + decoder: "auto".into(), + show_stats: true, + library_enabled: false, } } } diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index 7f7249a..caca9ec 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -1,9 +1,14 @@ -//! The hosts page: saved (trusted) hosts, live mDNS discovery, manual connect entry. +//! The hosts page: adaptive card grids for saved (trusted/paired) and mDNS-discovered +//! hosts, matching the other clients' look — avatar + name + `addr:port` + status pills, +//! online pips on saved cards, dashed discovered cards, an overflow menu, an add-host +//! dialog, and a connect-failure banner. Both grids re-render from one state snapshot +//! (known hosts on disk + the live advert map), so dedup and the online pips stay +//! consistent on every change. -use crate::discovery::{self, DiscoveredHost}; -use crate::trust::KnownHosts; +use crate::discovery::{self, DiscoveredHost, DiscoveryEvent}; +use crate::trust::{KnownHost, KnownHosts, Settings}; use adw::prelude::*; -use gtk::glib; +use gtk::{gio, glib}; use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; @@ -20,252 +25,158 @@ pub struct ConnectRequest { pub port: u16, pub fp_hex: Option, pub pair_optional: bool, + /// A library title to launch on connect (`(library id, display name)`, e.g. + /// `("steam:570", "Dota 2")`) — set by the library page's card activation; the id + /// rides the Hello and the name titles the stream page. `None` = plain desktop session. + pub launch: Option<(String, String)>, } -pub fn new( - on_connect: Rc, - on_settings: Rc, - on_speed_test: Rc, -) -> adw::NavigationPage { - let list = gtk::ListBox::new(); - list.add_css_class("boxed-list"); - list.set_selection_mode(gtk::SelectionMode::None); - let placeholder = gtk::Label::new(Some("Searching the LAN for hosts…")); - placeholder.add_css_class("dim-label"); - placeholder.set_margin_top(24); - placeholder.set_margin_bottom(24); - list.set_placeholder(Some(&placeholder)); +impl ConnectRequest { + /// The key the hosts page tracks an in-flight connect under (the card that swaps its + /// avatar for a spinner): the fingerprint when known, else the address. + pub fn card_key(&self) -> String { + self.fp_hex + .clone() + .unwrap_or_else(|| format!("{}:{}", self.addr, self.port)) + } +} - // key → (row, latest advert); the activation closure looks the advert up by key so - // re-adverts (new address, pairing flipped) take effect without rebuilding rows. - type Rows = Rc>>; - let rows: Rows = Rc::new(RefCell::new(HashMap::new())); +/// The actions the page hands off to the app shell (trust gate, speed test, PIN pairing, +/// the library browser). +pub struct HostsCallbacks { + pub on_connect: Rc, + pub on_speed_test: Rc, + pub on_pair: Rc, + pub on_library: Rc, +} - { - let rx = discovery::browse(); - let rows = rows.clone(); - let list = list.downgrade(); - let on_connect = on_connect.clone(); - glib::spawn_future_local(async move { - while let Ok(host) = rx.recv().await { - let Some(list) = list.upgrade() else { break }; - let mut map = rows.borrow_mut(); - let subtitle = format!( - "{}:{} · pairing {}", - host.addr, - host.port, - if host.pair.is_empty() { - "optional" - } else { - &host.pair - } - ); - if let Some((row, stored)) = map.get_mut(&host.key) { - row.set_title(&host.name); - row.set_subtitle(&subtitle); - *stored = host; - } else { - let row = adw::ActionRow::builder() - .title(&host.name) - .subtitle(&subtitle) - .activatable(true) - .build(); - row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); - { - let rows = rows.clone(); - let key = host.key.clone(); - let on_connect = on_connect.clone(); - row.connect_activated(move |_| { - if let Some((_, h)) = rows.borrow().get(&key) { - on_connect(ConnectRequest { - name: h.name.clone(), - addr: h.addr.clone(), - port: h.port, - fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()), - // TOFU is offered only when the host explicitly opts in - // with pair=optional; required/empty means mandatory PIN. - pair_optional: h.pair == "optional", - }); - } - }); - } - list.append(&row); - map.insert(host.key.clone(), (row, host)); - } - } - }); +/// The page plus the handle the launch path drives: connect-failure banner and the +/// per-card connecting spinner. Held by the `App` (`app.hosts_ui()`). +pub struct HostsUi { + pub page: adw::NavigationPage, + state: Rc, +} + +impl HostsUi { + /// Surface a connect failure at the top of the page (dismissible; replaces raw-error toasts). + pub fn show_error(&self, msg: &str) { + self.state.banner.set_title(msg); + self.state.banner.set_revealed(true); } - // Manual connect: host:port (punktfunk/1 default port 9777). - let manual = adw::EntryRow::builder().title("host:port").build(); - let connect_btn = gtk::Button::with_label("Connect"); - connect_btn.set_valign(gtk::Align::Center); - connect_btn.add_css_class("suggested-action"); - manual.add_suffix(&connect_btn); - let submit = { - let manual = manual.clone(); - let on_connect = on_connect.clone(); - move || { - let text = manual.text().to_string(); - let text = text.trim(); - if text.is_empty() { - return; - } - let (addr, port) = match text.rsplit_once(':') { - Some((a, p)) => match p.parse::() { - Ok(port) => (a.to_string(), port), - Err(_) => return, - }, - None => (text.to_string(), 9777), - }; - on_connect(ConnectRequest { - name: addr.clone(), - addr, - port, - fp_hex: None, - // Manual entry carries no advertised policy — never eligible for TOFU. - pair_optional: false, - }); - } - }; - { - let submit = submit.clone(); - connect_btn.connect_clicked(move |_| submit()); + pub fn clear_error(&self) { + self.state.banner.set_revealed(false); } - manual.connect_entry_activated(move |_| submit()); - let manual_list = gtk::ListBox::new(); - manual_list.add_css_class("boxed-list"); - manual_list.set_selection_mode(gtk::SelectionMode::None); - manual_list.append(&manual); + /// Mark the card matching `key` (see `ConnectRequest::card_key`) as connecting — + /// spinner in place of the avatar, card insensitive. `None` restores all cards. + pub fn set_connecting(&self, key: Option) { + *self.state.connecting.borrow_mut() = key; + rebuild(&self.state); + } - // Saved (trusted/paired) hosts — reachable even when mDNS isn't. Rebuilt every time - // the page is shown, so fresh TOFU/pairing entries appear on return. - let saved_label = gtk::Label::new(Some("Saved hosts")); - saved_label.add_css_class("heading"); - saved_label.set_halign(gtk::Align::Start); - let saved_list = gtk::ListBox::new(); - saved_list.add_css_class("boxed-list"); - saved_list.set_selection_mode(gtk::SelectionMode::None); - let rebuild_saved = { - let saved_list = saved_list.clone(); - let saved_label = saved_label.clone(); - let on_connect = on_connect.clone(); - let on_speed_test = on_speed_test.clone(); - move || { - saved_list.remove_all(); - let known = KnownHosts::load(); - saved_label.set_visible(!known.hosts.is_empty()); - saved_list.set_visible(!known.hosts.is_empty()); - for k in &known.hosts { - let row = adw::ActionRow::builder() - .title(&k.name) - .subtitle(format!( - "{}:{}{}", - k.addr, - k.port, - if k.paired { - " · paired" - } else { - " · trusted" - } - )) - .activatable(true) - .build(); - let req = ConnectRequest { - name: k.name.clone(), - addr: k.addr.clone(), - port: k.port, - fp_hex: Some(k.fp_hex.clone()), - // Saved host: its fp is already pinned, so this routes to a silent - // pinned connect; TOFU eligibility is irrelevant. - pair_optional: false, - }; - // Forget this host (drops the pinned fingerprint — a later connect re-pairs). - // Confirmed first, since it's destructive and a misclick on the Deck is easy. - let remove_btn = gtk::Button::from_icon_name("user-trash-symbolic"); - remove_btn.set_tooltip_text(Some("Remove saved host")); - remove_btn.set_valign(gtk::Align::Center); - remove_btn.add_css_class("flat"); - { - let fp = k.fp_hex.clone(); - let name = k.name.clone(); - let saved_list = saved_list.clone(); - let saved_label = saved_label.clone(); - let row = row.clone(); - remove_btn.connect_clicked(move |_| { - let dialog = adw::AlertDialog::new( - Some("Remove saved host?"), - Some(&format!( - "Forget “{name}”? You'll need to pair (or trust) it again to reconnect." - )), - ); - dialog.add_responses(&[("cancel", "Cancel"), ("remove", "Remove")]); - dialog.set_response_appearance( - "remove", - adw::ResponseAppearance::Destructive, - ); - dialog.set_default_response(Some("cancel")); - dialog.set_close_response("cancel"); - { - // Scoped clones for the response handler so `row` survives for present(). - let fp = fp.clone(); - let saved_list = saved_list.clone(); - let saved_label = saved_label.clone(); - let row = row.clone(); - dialog.connect_response(Some("remove"), move |_, _| { - let mut known = KnownHosts::load(); - known.remove_by_fp(&fp); - let _ = known.save(); - saved_list.remove(&row); - let empty = known.hosts.is_empty(); - saved_list.set_visible(!empty); - saved_label.set_visible(!empty); - }); - } - dialog.present(Some(&row)); - }); - } - row.add_suffix(&remove_btn); - let speed_btn = gtk::Button::from_icon_name("network-transmit-receive-symbolic"); - speed_btn.set_tooltip_text(Some("Test network speed")); - speed_btn.set_valign(gtk::Align::Center); - speed_btn.add_css_class("flat"); - { - let on_speed_test = on_speed_test.clone(); - let req = req.clone(); - speed_btn.connect_clicked(move |_| on_speed_test(req.clone())); - } - row.add_suffix(&speed_btn); - row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); - let on_connect = on_connect.clone(); - row.connect_activated(move |_| on_connect(req.clone())); - saved_list.append(&row); - } - } + /// Feed one advert through the same path the mDNS stream uses (CI screenshot scenes). + pub fn inject_advert(&self, host: DiscoveredHost) { + self.state + .adverts + .borrow_mut() + .insert(host.key.clone(), host); + rebuild(&self.state); + } + + /// The "+" add-host dialog (name optional / address / port), also reachable from the + /// empty state. Reuses the manual-connect plumbing: submit runs the trust gate. + pub fn show_add_host(&self) { + add_host_dialog(&self.state); + } + + /// Re-render both grids (e.g. the library toggle changed in Preferences, which adds/ + /// removes the saved cards' "Browse library…" menu item). + pub fn refresh(&self) { + rebuild(&self.state); + } + + /// The advertised mgmt port for the host `req` points at, when a matching live advert + /// carries the `mgmt` TXT — the library client's port override (default otherwise). + pub fn mgmt_port_for(&self, req: &ConnectRequest) -> Option { + let adverts = self.state.adverts.borrow(); + adverts + .values() + .find(|a| { + req.fp_hex + .as_deref() + .is_some_and(|fp| !a.fp_hex.is_empty() && a.fp_hex == fp) + || (a.addr == req.addr && a.port == req.port) + }) + .and_then(|a| a.mgmt_port) + } +} + +/// Everything the grids re-render from, plus the widgets they render into. +struct State { + stack: gtk::Stack, + banner: adw::Banner, + saved_heading: gtk::Label, + saved_flow: gtk::FlowBox, + disc_flow: gtk::FlowBox, + searching: gtk::Box, + /// Live mDNS adverts, keyed by the advert key — the source for the discovered grid, + /// the saved cards' online pips, and dedup. + adverts: RefCell>, + /// `card_key` of the connect currently in flight, if any. + connecting: RefCell>, + /// App settings — read on every rebuild for the experimental library-item gate. + settings: Rc>, + cbs: HostsCallbacks, +} + +pub fn new(settings: Rc>, cbs: HostsCallbacks) -> HostsUi { + let make_flow = || { + gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::None) + .activate_on_single_click(true) + .homogeneous(true) + .min_children_per_line(1) + .max_children_per_line(4) + .column_spacing(12) + .row_spacing(12) + .build() }; - rebuild_saved(); + let heading = |text: &str| { + let l = gtk::Label::new(Some(text)); + l.add_css_class("heading"); + l.set_halign(gtk::Align::Start); + l + }; + let saved_heading = heading("Saved hosts"); + let saved_flow = make_flow(); + let disc_heading = heading("On this network"); + let disc_flow = make_flow(); - let content = gtk::Box::new(gtk::Orientation::Vertical, 18); + // Shown under the discovered heading while no (unsaved) advert is live yet. + let searching = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let spinner = gtk::Spinner::new(); + spinner.start(); + searching.append(&spinner); + let searching_label = gtk::Label::new(Some("Searching the LAN…")); + searching_label.add_css_class("dim-label"); + searching.append(&searching_label); + searching.set_margin_top(6); + searching.set_margin_bottom(6); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 12); content.set_margin_top(24); content.set_margin_bottom(24); content.set_margin_start(12); content.set_margin_end(12); - content.append(&saved_label); - content.append(&saved_list); - let discovered_label = gtk::Label::new(Some("Hosts on this network")); - discovered_label.add_css_class("heading"); - discovered_label.set_halign(gtk::Align::Start); - content.append(&discovered_label); - content.append(&list); - let manual_label = gtk::Label::new(Some("Manual connection")); - manual_label.add_css_class("heading"); - manual_label.set_halign(gtk::Align::Start); - content.append(&manual_label); - content.append(&manual_list); + content.append(&saved_heading); + content.append(&saved_flow); + content.append(&disc_heading); + content.append(&searching); + content.append(&disc_flow); let clamp = adw::Clamp::builder() - .maximum_size(560) + .maximum_size(1100) .child(&content) .build(); let scrolled = gtk::ScrolledWindow::builder() @@ -273,21 +184,489 @@ pub fn new( .child(&clamp) .build(); + // No saved hosts AND nothing on the LAN → the whole page is the empty state. + let empty = adw::StatusPage::builder() + .icon_name("network-workgroup-symbolic") + .title("No hosts yet") + .description("Hosts on your network appear here automatically.\nAdd one by address with +.") + .build(); + let add_btn = gtk::Button::with_label("Add host"); + add_btn.add_css_class("pill"); + add_btn.add_css_class("suggested-action"); + add_btn.set_halign(gtk::Align::Center); + add_btn.set_action_name(Some("win.add-host")); + empty.set_child(Some(&add_btn)); + + let stack = gtk::Stack::new(); + stack.add_named(&scrolled, Some("grid")); + stack.add_named(&empty, Some("empty")); + + // Connect failures land here (launch.rs routes on_failed/on_ended), not in toasts. + let banner = adw::Banner::new(""); + banner.set_button_label(Some("Dismiss")); + banner.connect_button_clicked(|b| b.set_revealed(false)); + let header = adw::HeaderBar::new(); - let settings_btn = gtk::Button::from_icon_name("preferences-system-symbolic"); - settings_btn.set_tooltip_text(Some("Preferences")); - settings_btn.connect_clicked(move |_| on_settings()); - header.pack_end(&settings_btn); + let add_host_btn = gtk::Button::from_icon_name("list-add-symbolic"); + add_host_btn.set_tooltip_text(Some("Add host")); + add_host_btn.set_action_name(Some("win.add-host")); + header.pack_start(&add_host_btn); + // Primary menu — the actions live on the window (installed in app.rs). + let menu = gio::Menu::new(); + menu.append(Some("Preferences"), Some("win.preferences")); + menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts")); + menu.append(Some("About Punktfunk"), Some("win.about")); + let menu_btn = gtk::MenuButton::builder() + .icon_name("open-menu-symbolic") + .menu_model(&menu) + .primary(true) + .tooltip_text("Main menu") + .build(); + header.pack_end(&menu_btn); let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header); - toolbar.set_content(Some(&scrolled)); + toolbar.add_top_bar(&banner); + toolbar.set_content(Some(&stack)); let page = adw::NavigationPage::builder() .title("Punktfunk") .tag("hosts") .child(&toolbar) .build(); - page.connect_shown(move |_| rebuild_saved()); - page + + let state = Rc::new(State { + stack, + banner, + saved_heading, + saved_flow, + disc_flow, + searching, + adverts: RefCell::new(HashMap::new()), + connecting: RefCell::new(None), + settings, + cbs, + }); + rebuild(&state); + + // Rebuilt every time the page is shown, so fresh TOFU/pairing entries appear on return. + { + let state = state.clone(); + page.connect_shown(move |_| rebuild(&state)); + } + + // Stream mDNS adverts into the map; every add/remove re-evaluates both grids (online + // pips + dedup included). + { + let rx = discovery::browse(); + let weak = Rc::downgrade(&state); + glib::spawn_future_local(async move { + while let Ok(event) = rx.recv().await { + let Some(state) = weak.upgrade() else { break }; + match event { + DiscoveryEvent::Resolved(h) => { + state.adverts.borrow_mut().insert(h.key.clone(), h); + } + DiscoveryEvent::Removed { fullname } => { + state + .adverts + .borrow_mut() + .retain(|_, a| a.fullname != fullname); + } + } + rebuild(&state); + } + }); + } + + HostsUi { page, state } +} + +/// Re-render both grids from disk + the advert map. Cheap (a handful of widgets) and +/// keeps every derived view — online pips, dedup, most-recent accent, spinner — in one +/// straight-line pass instead of incremental row surgery. +fn rebuild(state: &Rc) { + let known = KnownHosts::load(); + let adverts = state.adverts.borrow(); + let connecting = state.connecting.borrow().clone(); + + // A saved host is ONLINE iff a live advert matches it (fingerprint, or address when + // the advert carries no fp) — same rule the Apple client uses. + let matches = |k: &KnownHost, a: &DiscoveredHost| { + (!a.fp_hex.is_empty() && a.fp_hex == k.fp_hex) || (a.addr == k.addr && a.port == k.port) + }; + let most_recent = known + .hosts + .iter() + .filter_map(|h| h.last_used.map(|t| (h.fp_hex.clone(), t))) + .max_by_key(|&(_, t)| t) + .map(|(fp, _)| fp); + + state.saved_flow.remove_all(); + for k in &known.hosts { + let online = adverts.values().any(|a| matches(k, a)); + let recent = most_recent.as_deref() == Some(k.fp_hex.as_str()); + state + .saved_flow + .append(&saved_card(state, k, online, recent, connecting.as_deref())); + } + + // The discovered grid only surfaces genuinely-new hosts: anything matching a saved + // entry renders as that saved card (with its pip now green) instead. + let mut fresh: Vec<&DiscoveredHost> = adverts + .values() + .filter(|a| !known.hosts.iter().any(|k| matches(k, a))) + .collect(); + fresh.sort_by(|a, b| a.name.cmp(&b.name).then(a.key.cmp(&b.key))); + state.disc_flow.remove_all(); + for a in &fresh { + state + .disc_flow + .append(&discovered_card(state, a, connecting.as_deref())); + } + + let have_saved = !known.hosts.is_empty(); + let have_disc = !fresh.is_empty(); + state.saved_heading.set_visible(have_saved); + state.saved_flow.set_visible(have_saved); + state.disc_flow.set_visible(have_disc); + state.searching.set_visible(!have_disc); + state + .stack + .set_visible_child_name(if have_saved || have_disc { + "grid" + } else { + "empty" + }); +} + +/// The shared card scaffold: avatar (or a spinner while connecting) over name over +/// `addr:port` over a status row, in a `.card` overlay (the overlay hosts the saved +/// card's corner menu). Returned as the FlowBox child so callers wire activation on it. +fn card_scaffold( + name: &str, + addr_line: &str, + status_row: >k::Box, + connecting: bool, +) -> (gtk::FlowBoxChild, gtk::Overlay) { + let content = gtk::Box::new(gtk::Orientation::Vertical, 6); + if connecting { + let spinner = gtk::Spinner::new(); + spinner.set_size_request(48, 48); + spinner.start(); + spinner.set_halign(gtk::Align::Center); + content.append(&spinner); + } else { + let avatar = adw::Avatar::new(48, Some(name), true); + avatar.set_halign(gtk::Align::Center); + content.append(&avatar); + } + let name_label = gtk::Label::new(Some(name)); + name_label.add_css_class("heading"); + name_label.set_ellipsize(gtk::pango::EllipsizeMode::Middle); + content.append(&name_label); + let addr_label = gtk::Label::new(Some(addr_line)); + addr_label.add_css_class("caption"); + addr_label.add_css_class("dim-label"); + addr_label.add_css_class("numeric"); + addr_label.set_ellipsize(gtk::pango::EllipsizeMode::Middle); + content.append(&addr_label); + status_row.set_halign(gtk::Align::Center); + status_row.set_margin_top(4); + content.append(status_row); + + let overlay = gtk::Overlay::new(); + overlay.set_child(Some(&content)); + overlay.add_css_class("card"); + overlay.add_css_class("pf-host-card"); + + let child = gtk::FlowBoxChild::new(); + child.set_child(Some(&overlay)); + if connecting { + child.set_sensitive(false); + } + (child, overlay) +} + +/// A small rounded status chip (`.pf-pill` + a colour variant class). +fn pill(text: &str, class: &str) -> gtk::Label { + let l = gtk::Label::new(Some(text)); + l.add_css_class("pf-pill"); + l.add_css_class(class); + l +} + +fn saved_card( + state: &Rc, + k: &KnownHost, + online: bool, + recent: bool, + connecting: Option<&str>, +) -> gtk::FlowBoxChild { + let req = ConnectRequest { + name: k.name.clone(), + addr: k.addr.clone(), + port: k.port, + fp_hex: Some(k.fp_hex.clone()), + // Saved host: its fp is already pinned, so this routes to a silent pinned + // connect; TOFU eligibility is irrelevant. + pair_optional: false, + launch: None, + }; + + // Presence pip + spelled-out state, then the trust pill. + let status = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let pip = gtk::Box::new(gtk::Orientation::Horizontal, 0); + pip.add_css_class("pf-pip"); + if online { + pip.add_css_class("pf-online"); + } + pip.set_valign(gtk::Align::Center); + status.append(&pip); + let presence = gtk::Label::new(Some(if online { "Online" } else { "Offline" })); + presence.add_css_class("caption"); + presence.add_css_class("dim-label"); + status.append(&presence); + status.append(&if k.paired { + pill("Paired", "pf-green") + } else { + pill("Trusted", "pf-accent") + }); + + let (child, overlay) = card_scaffold( + &k.name, + &format!("{}:{}", k.addr, k.port), + &status, + connecting == Some(k.fp_hex.as_str()), + ); + if recent { + overlay.add_css_class("pf-recent"); + } + + // Overflow menu (top-right; also on right-click): pair / speed test / rename / forget. + let actions = gio::SimpleActionGroup::new(); + let add = |name: &str, f: Box| { + let a = gio::SimpleAction::new(name, None); + a.connect_activate(move |_, _| f()); + actions.add_action(&a); + }; + { + let cb = state.cbs.on_pair.clone(); + let req = req.clone(); + add("pair", Box::new(move || cb(req.clone()))); + } + { + let cb = state.cbs.on_speed_test.clone(); + let req = req.clone(); + add("speed", Box::new(move || cb(req.clone()))); + } + { + let cb = state.cbs.on_library.clone(); + let req = req.clone(); + add("library", Box::new(move || cb(req.clone()))); + } + { + let state = state.clone(); + let fp = k.fp_hex.clone(); + let name = k.name.clone(); + add( + "rename", + Box::new(move || rename_dialog(&state, &fp, &name)), + ); + } + { + let state = state.clone(); + let fp = k.fp_hex.clone(); + let name = k.name.clone(); + add( + "forget", + Box::new(move || forget_dialog(&state, &fp, &name)), + ); + } + overlay.insert_action_group("card", Some(&actions)); + + let menu = gio::Menu::new(); + menu.append(Some("Pair with PIN…"), Some("card.pair")); + menu.append(Some("Test network speed…"), Some("card.speed")); + // Experimental (Preferences gate, Apple parity): browse the host's game library. The + // item is offered on every saved card — an unpaired host answers with the friendly + // "not paired" error state rather than the entry hiding itself. + if state.settings.borrow().library_enabled { + menu.append(Some("Browse library…"), Some("card.library")); + } + menu.append(Some("Rename…"), Some("card.rename")); + menu.append(Some("Forget"), Some("card.forget")); + let menu_btn = gtk::MenuButton::builder() + .icon_name("view-more-symbolic") + .menu_model(&menu) + .halign(gtk::Align::End) + .valign(gtk::Align::Start) + .build(); + menu_btn.add_css_class("flat"); + overlay.add_overlay(&menu_btn); + let right_click = gtk::GestureClick::builder().button(3).build(); + { + let menu_btn = menu_btn.clone(); + right_click.connect_pressed(move |_, _, _, _| menu_btn.popup()); + } + overlay.add_controller(right_click); + + let on_connect = state.cbs.on_connect.clone(); + child.connect_activate(move |_| on_connect(req.clone())); + child +} + +fn discovered_card( + state: &Rc, + a: &DiscoveredHost, + connecting: Option<&str>, +) -> gtk::FlowBoxChild { + let req = ConnectRequest { + name: a.name.clone(), + addr: a.addr.clone(), + port: a.port, + fp_hex: (!a.fp_hex.is_empty()).then(|| a.fp_hex.clone()), + // TOFU is offered only when the host explicitly opts in with pair=optional; + // required/empty means mandatory PIN. + pair_optional: a.pair == "optional", + launch: None, + }; + + let status = gtk::Box::new(gtk::Orientation::Horizontal, 6); + status.append(&if req.pair_optional { + pill("Open", "pf-neutral") + } else { + pill("PIN", "pf-accent") + }); + + let is_connecting = connecting == Some(req.card_key().as_str()); + let (child, overlay) = card_scaffold( + &a.name, + &format!("{}:{}", a.addr, a.port), + &status, + is_connecting, + ); + overlay.add_css_class("pf-discovered"); + + // Tap-to-connect only (parity with Android's discovered cards). + let on_connect = state.cbs.on_connect.clone(); + child.connect_activate(move |_| on_connect(req.clone())); + child +} + +/// Rename a saved host — an entry in an alert, then upsert + refresh. +fn rename_dialog(state: &Rc, fp_hex: &str, current: &str) { + let entry = gtk::Entry::builder() + .text(current) + .activates_default(true) + .build(); + let dialog = adw::AlertDialog::new(Some("Rename Host"), None); + dialog.set_extra_child(Some(&entry)); + dialog.add_responses(&[("cancel", "Cancel"), ("rename", "Rename")]); + dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("rename")); + dialog.set_close_response("cancel"); + { + let state = state.clone(); + let fp = fp_hex.to_string(); + dialog.connect_response(Some("rename"), move |_, _| { + let name = entry.text().trim().to_string(); + if name.is_empty() { + return; + } + let mut known = KnownHosts::load(); + if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) { + h.name = name; + let _ = known.save(); + } + rebuild(&state); + }); + } + dialog.present(Some(&state.stack)); +} + +/// Forget this host (drops the pinned fingerprint — a later connect re-pairs). +/// Confirmed first, since it's destructive and a misclick on the Deck is easy. +fn forget_dialog(state: &Rc, fp_hex: &str, name: &str) { + let dialog = adw::AlertDialog::new( + Some("Remove saved host?"), + Some(&format!( + "Forget “{name}”? You'll need to pair (or trust) it again to reconnect." + )), + ); + dialog.add_responses(&[("cancel", "Cancel"), ("remove", "Remove")]); + dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + { + let state = state.clone(); + let fp = fp_hex.to_string(); + dialog.connect_response(Some("remove"), move |_, _| { + let mut known = KnownHosts::load(); + known.remove_by_fp(&fp); + let _ = known.save(); + rebuild(&state); + }); + } + dialog.present(Some(&state.stack)); +} + +/// "+": name (optional) / address / port — the Apple AddHostSheet / Android dialog +/// equivalent of the old inline entry. Submit runs the normal trust gate (`on_connect`). +fn add_host_dialog(state: &Rc) { + let list = gtk::ListBox::new(); + list.add_css_class("boxed-list"); + list.set_selection_mode(gtk::SelectionMode::None); + let name_row = adw::EntryRow::builder().title("Name (optional)").build(); + let addr_row = adw::EntryRow::builder().title("Address").build(); + let port_row = adw::EntryRow::builder().title("Port").text("9777").build(); + list.append(&name_row); + list.append(&addr_row); + list.append(&port_row); + list.set_size_request(320, -1); + + let dialog = adw::AlertDialog::new(Some("Add Host"), None); + dialog.set_extra_child(Some(&list)); + dialog.add_responses(&[("cancel", "Cancel"), ("connect", "Connect")]); + dialog.set_response_appearance("connect", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("connect")); + dialog.set_close_response("cancel"); + dialog.set_response_enabled("connect", false); + { + let dialog = dialog.clone(); + addr_row.connect_changed(move |row| { + dialog.set_response_enabled("connect", !row.text().trim().is_empty()); + }); + } + { + let on_connect = state.cbs.on_connect.clone(); + let (name_row, addr_row, port_row) = (name_row.clone(), addr_row.clone(), port_row.clone()); + dialog.connect_response(Some("connect"), move |_, _| { + let text = addr_row.text().trim().to_string(); + if text.is_empty() { + return; + } + // A pasted `host:port` wins over the port field; otherwise the field (default 9777). + let (addr, port) = match text.rsplit_once(':') { + Some((a, p)) if p.parse::().is_ok() => { + (a.to_string(), p.parse::().unwrap()) + } + _ => ( + text.clone(), + port_row.text().trim().parse::().unwrap_or(9777), + ), + }; + let name = name_row.text().trim().to_string(); + on_connect(ConnectRequest { + name: if name.is_empty() { addr.clone() } else { name }, + addr, + port, + fp_hex: None, + // Manual entry carries no advertised policy — never eligible for TOFU. + pair_optional: false, + launch: None, + }); + }); + } + dialog.present(Some(&state.stack)); } diff --git a/clients/linux/src/ui_library.rs b/clients/linux/src/ui_library.rs new file mode 100644 index 0000000..59d4cff --- /dev/null +++ b/clients/linux/src/ui_library.rs @@ -0,0 +1,386 @@ +//! The game-library page (the Apple `LibraryView` ported): a poster grid of the host's +//! unified library fetched over the management API (`library.rs`), pushed onto the nav +//! stack from a saved card's "Browse library…" action. Poster art loads asynchronously +//! (worker threads → texture on the main loop) with a monogram placeholder, and tapping +//! a title starts a session that asks the host to launch it (the library id rides the +//! Hello via `ConnectRequest::launch`). + +use crate::app::App; +use crate::library::{self, GameEntry}; +use crate::trust; +use crate::ui_hosts::ConnectRequest; +use adw::prelude::*; +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 +/// art consumer (its weak upgrade fails). +struct State { + app: Rc, + /// The host this library belongs to — cards clone it and add `launch`. + req: ConnectRequest, + stack: gtk::Stack, + flow: gtk::FlowBox, + error_page: adw::StatusPage, + /// Per-page poster cache (entry id → texture) — a Retry re-renders without refetching. + art: RefCell>, + /// The Picture each entry currently renders into (rebuilt per render), so async art + /// results land on the right card. + pics: RefCell>, + /// Screenshot mode: render injected entries only, never touch the network. + mock: Cell, +} + +/// Open the library page for a saved host and start the fetch. +pub fn open(app: Rc, req: ConnectRequest) { + let state = build(app.clone(), req); + load(&state); +} + +/// Screenshot-scene entry: render injected entries (plus pre-seeded textures, keyed by +/// entry id) with no host and no network — the CI `library` scene. +pub fn open_mock( + app: Rc, + req: ConnectRequest, + games: Vec, + art: Vec<(String, gdk::Texture)>, +) { + let state = build(app.clone(), req); + state.mock.set(true); + state.art.borrow_mut().extend(art); + if games.is_empty() { + state.stack.set_visible_child_name("empty"); + } else { + render(&state, &games); + state.stack.set_visible_child_name("grid"); + } +} + +/// Build the page (loading / error / empty / grid states in a stack) and push it. +fn build(app: Rc, req: ConnectRequest) -> Rc { + let flow = gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::None) + .activate_on_single_click(true) + .homogeneous(true) + .min_children_per_line(2) + .max_children_per_line(6) + .column_spacing(12) + .row_spacing(18) + .valign(gtk::Align::Start) + .build(); + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + content.set_margin_top(24); + content.set_margin_bottom(24); + content.set_margin_start(12); + content.set_margin_end(12); + content.append(&flow); + let clamp = adw::Clamp::builder() + .maximum_size(1100) + .child(&content) + .build(); + let scrolled = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .child(&clamp) + .build(); + + let loading = gtk::Box::new(gtk::Orientation::Vertical, 12); + loading.set_valign(gtk::Align::Center); + let spinner = gtk::Spinner::new(); + spinner.set_size_request(32, 32); + spinner.start(); + spinner.set_halign(gtk::Align::Center); + loading.append(&spinner); + let loading_label = gtk::Label::new(Some("Loading library…")); + loading_label.add_css_class("dim-label"); + loading.append(&loading_label); + + let error_page = adw::StatusPage::builder() + .icon_name("dialog-error-symbolic") + .title("Couldn't load the library") + .build(); + let retry = gtk::Button::with_label("Retry"); + retry.add_css_class("pill"); + retry.add_css_class("suggested-action"); + retry.set_halign(gtk::Align::Center); + error_page.set_child(Some(&retry)); + + let empty = adw::StatusPage::builder() + .icon_name("applications-games-symbolic") + .title("No games found") + .description( + "No games found on this host. Install Steam titles or add custom \ + entries in the host's web console.", + ) + .build(); + + let stack = gtk::Stack::new(); + stack.add_named(&loading, Some("loading")); + stack.add_named(&error_page, Some("error")); + stack.add_named(&empty, Some("empty")); + stack.add_named(&scrolled, Some("grid")); + + let header = adw::HeaderBar::new(); + let reload = gtk::Button::from_icon_name("view-refresh-symbolic"); + reload.set_tooltip_text(Some("Reload")); + header.pack_end(&reload); + + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.set_content(Some(&stack)); + + let page = adw::NavigationPage::builder() + .title(format!("{} — Library", req.name)) + .child(&toolbar) + .build(); + + let state = Rc::new(State { + app: app.clone(), + req, + stack, + flow, + error_page, + art: RefCell::new(HashMap::new()), + pics: RefCell::new(HashMap::new()), + mock: Cell::new(false), + }); + { + let state = state.clone(); + reload.connect_clicked(move |_| load(&state)); + } + { + let state = state.clone(); + retry.connect_clicked(move |_| load(&state)); + } + app.nav.push(&page); + state +} + +/// The mgmt port for this host: the live mDNS `mgmt` TXT when the host is advertising, +/// else the well-known default (Apple's `effectiveMgmtPort`). +fn mgmt_port(state: &State) -> u16 { + state + .app + .hosts_ui() + .and_then(|h| h.mgmt_port_for(&state.req)) + .unwrap_or(library::DEFAULT_MGMT_PORT) +} + +/// Fetch the library off the main thread and route the result into the grid or the +/// error/empty states. +fn load(state: &Rc) { + if state.mock.get() { + return; // screenshot scene renders injected entries only + } + state.stack.set_visible_child_name("loading"); + let port = mgmt_port(state); + let addr = state.req.addr.clone(); + let identity = state.app.identity.clone(); + let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32); + let (tx, rx) = async_channel::bounded(1); + std::thread::Builder::new() + .name("punktfunk-library".into()) + .spawn(move || { + let _ = tx.send_blocking(library::fetch_games(&addr, port, &identity, pin)); + }) + .expect("spawn library thread"); + let weak = Rc::downgrade(state); + glib::spawn_future_local(async move { + let Ok(result) = rx.recv().await else { return }; + let Some(state) = weak.upgrade() else { return }; + match result { + Ok(games) if games.is_empty() => state.stack.set_visible_child_name("empty"), + Ok(games) => { + render(&state, &games); + state.stack.set_visible_child_name("grid"); + load_art(&state, &games); + } + Err(e) => { + state.error_page.set_description(Some(&e.to_string())); + state.stack.set_visible_child_name("error"); + } + } + }); +} + +/// (Re)build the poster grid from one library snapshot. Cached textures apply +/// immediately; the rest keep their monogram placeholder until `load_art` delivers. +fn render(state: &Rc, games: &[GameEntry]) { + state.flow.remove_all(); + state.pics.borrow_mut().clear(); + for game in games { + state.flow.append(&game_card(state, game)); + } +} + +/// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a +/// monogram placeholder underneath the async art. Activation starts a session launching +/// this title (silent on a pinned host — the normal trust gate applies). +fn game_card(state: &Rc, game: &GameEntry) -> gtk::FlowBoxChild { + let monogram = gtk::Label::new(Some(&initials(&game.title))); + monogram.add_css_class("pf-poster-monogram"); + monogram.set_halign(gtk::Align::Center); + monogram.set_valign(gtk::Align::Center); + let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0); + placeholder.append(&monogram); + monogram.set_vexpand(true); + + let pic = gtk::Picture::new(); + pic.set_content_fit(gtk::ContentFit::Cover); + if let Some(tex) = state.art.borrow().get(&game.id) { + pic.set_paintable(Some(tex)); + } + state.pics.borrow_mut().insert(game.id.clone(), pic.clone()); + + let badge = gtk::Label::new(Some(store_label(&game.store))); + badge.add_css_class("pf-pill"); + badge.add_css_class("pf-store-badge"); + badge.set_halign(gtk::Align::Start); + badge.set_valign(gtk::Align::Start); + badge.set_margin_start(6); + badge.set_margin_top(6); + + let poster = gtk::Overlay::new(); + poster.set_child(Some(&placeholder)); + poster.add_overlay(&pic); + poster.add_overlay(&badge); + poster.add_css_class("pf-poster"); + poster.set_overflow(gtk::Overflow::Hidden); + poster.set_size_request(150, 225); + poster.set_halign(gtk::Align::Center); + + let title = gtk::Label::new(Some(&game.title)); + title.add_css_class("caption"); + title.set_ellipsize(gtk::pango::EllipsizeMode::End); + title.set_max_width_chars(16); + title.set_tooltip_text(Some(&game.title)); + + let card = gtk::Box::new(gtk::Orientation::Vertical, 6); + card.append(&poster); + card.append(&title); + + let child = gtk::FlowBoxChild::new(); + child.set_child(Some(&card)); + let app = state.app.clone(); + let mut req = state.req.clone(); + req.launch = Some((game.id.clone(), game.title.clone())); + child.connect_activate(move |_| crate::ui_trust::initiate_connect(app.clone(), req.clone())); + child +} + +/// Fetch poster art for every uncached entry on a small worker pool, walking each +/// entry's candidates in the Apple fallback order (portrait → header → hero) and +/// texturing the first that loads on the main loop. +fn load_art(state: &Rc, games: &[GameEntry]) { + let port = mgmt_port(state); + let base = library::base_url(&state.req.addr, port); + let jobs: VecDeque<(String, Vec)> = { + let cache = state.art.borrow(); + games + .iter() + .filter(|g| !cache.contains_key(&g.id)) + .map(|g| (g.id.clone(), g.art.poster_candidates(&base))) + .filter(|(_, candidates)| !candidates.is_empty()) + .collect() + }; + if jobs.is_empty() { + return; + } + 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)>(); + 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 weak = Rc::downgrade(state); + glib::spawn_future_local(async move { + while let Ok((id, bytes)) = rx.recv().await { + let Some(state) = weak.upgrade() else { break }; + // Texture decode happens here on the main loop — posters are small (tens of + // KB), and `from_bytes` handles jpeg/png alike. + match gdk::Texture::from_bytes(&glib::Bytes::from_owned(bytes)) { + Ok(tex) => { + if let Some(pic) = state.pics.borrow().get(&id) { + pic.set_paintable(Some(&tex)); + } + state.art.borrow_mut().insert(id, tex); + } + Err(e) => tracing::debug!(%id, error = %e, "undecodable poster"), + } + } + }); +} + +/// 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 { + match store { + "steam" => "Steam", + "custom" => "Custom", + "heroic" => "Heroic", + "lutris" => "Lutris", + "epic" => "Epic", + "gog" => "GOG", + "xbox" => "Xbox", + _ => "Game", + } +} + +/// Monogram for the placeholder tile: the first letters of the first two words. +fn initials(title: &str) -> String { + title + .split_whitespace() + .take(2) + .filter_map(|w| w.chars().next()) + .flat_map(char::to_uppercase) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initials_take_two_words() { + assert_eq!(initials("Dota 2"), "D2"); + assert_eq!(initials("half-life"), "H"); + assert_eq!(initials("The Witness III"), "TW"); + assert_eq!(initials(""), ""); + } +} diff --git a/clients/linux/src/ui_settings.rs b/clients/linux/src/ui_settings.rs index 46af284..bba45be 100644 --- a/clients/linux/src/ui_settings.rs +++ b/clients/linux/src/ui_settings.rs @@ -21,6 +21,7 @@ const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"] /// Codec setting values (persisted) paired with their display labels below. const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"]; const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"]; +const DECODERS: &[&str] = &["auto", "vaapi", "software"]; /// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page. const APP_LICENSE: &str = concat!( @@ -34,8 +35,9 @@ const APP_LICENSE: &str = concat!( /// scripts/gen-third-party-notices.sh; shown as a Legal section in the About dialog). const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt"); -/// Show the About dialog (app license + the third-party-software Legal section). -fn show_about(parent: &impl IsA) { +/// Show the About dialog (app license + the third-party-software Legal section) — reached +/// from the primary menu (app.rs `win.about`). +pub fn show_about(parent: &impl IsA) { let about = adw::AboutDialog::builder() .application_name("punktfunk") .developer_name("unom") @@ -65,10 +67,13 @@ fn show_about(parent: &impl IsA) { about.present(Some(parent)); } +/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid +/// there so the experimental library toggle takes effect without a nav round-trip). pub fn show( parent: &impl IsA, settings: Rc>, gamepads: &crate::gamepad::GamepadService, + on_closed: impl Fn() + 'static, ) { let page = adw::PreferencesPage::new(); @@ -120,10 +125,25 @@ pub fn show( "gamescope", ])) .build(); + let decoder_row = adw::ComboRow::builder() + .title("Video decoder") + .subtitle("Automatic tries VAAPI hardware decode, then software") + .model(>k::StringList::new(&[ + "Automatic (VAAPI → software)", + "Hardware (VAAPI)", + "Software", + ])) + .build(); + let stats_row = adw::SwitchRow::builder() + .title("Show statistics overlay") + .subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live") + .build(); stream.add(&res_row); stream.add(&hz_row); stream.add(&bitrate_row); stream.add(&compositor_row); + stream.add(&decoder_row); + stream.add(&stats_row); let input = adw::PreferencesGroup::builder().title("Input").build(); // Which physical controller forwards as pad 0: automatic = the most recently @@ -208,23 +228,24 @@ pub fn show( .build(); audio.add(&mic_row); - let about = adw::PreferencesGroup::builder().title("About").build(); - let licenses_row = adw::ActionRow::builder() - .title("Third-party licenses") - .subtitle("Open-source software used by punktfunk") - .activatable(true) + // Experimental — mirrors the Apple client's Experimental section (wording included). + let experimental = adw::PreferencesGroup::builder() + .title("Experimental") .build(); - licenses_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); - { - let about_parent: gtk::Widget = parent.clone().upcast(); - licenses_row.connect_activated(move |_| show_about(&about_parent)); - } - about.add(&licenses_row); + let library_row = adw::SwitchRow::builder() + .title("Show game library") + .subtitle( + "Adds a “Browse library…” action to each saved host that lists its games \ + (Steam + custom) via the host's management API — works once you've paired", + ) + .build(); + experimental.add(&library_row); + // About (with the license/third-party Legal pages) lives in the primary menu now. page.add(&stream); page.add(&input); page.add(&audio); - page.add(&about); + page.add(&experimental); // Seed from the current settings. { @@ -244,8 +265,12 @@ pub fn show( .position(|&c| c == s.compositor) .unwrap_or(0); compositor_row.set_selected(comp_i as u32); + let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0); + decoder_row.set_selected(dec_i as u32); + stats_row.set_active(s.show_stats); inhibit_row.set_active(s.inhibit_shortcuts); mic_row.set_active(s.mic_enabled); + library_row.set_active(s.library_enabled); surround_row.set_selected(match s.audio_channels { 6 => 1, 8 => 2, @@ -267,6 +292,8 @@ pub fn show( s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string(); s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)] .to_string(); + s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string(); + s.show_stats = stats_row.is_active(); s.inhibit_shortcuts = inhibit_row.is_active(); s.mic_enabled = mic_row.is_active(); s.audio_channels = match surround_row.selected() { @@ -275,7 +302,10 @@ pub fn show( _ => 2, }; s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string(); + s.library_enabled = library_row.is_active(); s.save(); + drop(s); + on_closed(); }); dialog.present(Some(parent)); } diff --git a/clients/linux/src/ui_stream.rs b/clients/linux/src/ui_stream.rs index b01d5a3..3e1ab77 100644 --- a/clients/linux/src/ui_stream.rs +++ b/clients/linux/src/ui_stream.rs @@ -16,7 +16,7 @@ use crate::keymap; use crate::session::Stats; -use crate::video::DecodedFrame; +use crate::video::{DecodedFrame, DecodedImage}; use adw::prelude::*; use gtk::{gdk, glib}; use punktfunk_core::client::NativeClient; @@ -26,21 +26,55 @@ use std::collections::HashSet; use std::rc::Rc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::time::{Duration, Instant}; pub struct StreamPage { pub page: adw::NavigationPage, stats_label: gtk::Label, + /// Median capture→paintable-set latency (ms) over the frame consumer's last 1 s + /// window — written there, folded into the OSD on each `Stats` event. + present_ms: Rc>, } impl StreamPage { pub fn update_stats(&self, s: Stats) { - self.stats_label.set_text(&format!( - "{:.0} fps · {:.1} Mbit/s · dec {:.1} ms · lat {:.1} ms", - s.fps, s.mbps, s.decode_ms, s.latency_ms - )); + let mut line = format!( + "{:.0} fps · {:.1} Mbit/s · dec {:.1} ms · lat {:.1} ms · present {:.1} ms", + s.fps, + s.mbps, + s.decode_ms, + s.latency_ms, + self.present_ms.get() + ); + // Which decoder actually ran this window (vaapi/software) — tracks a fallback. + if !s.decoder.is_empty() { + line.push_str(" · "); + line.push_str(s.decoder); + } + self.stats_label.set_text(&line); } } +/// Everything the stream page needs from the app + session that own it. +pub struct StreamPageArgs { + pub window: adw::ApplicationWindow, + pub connector: Arc, + pub frames: async_channel::Receiver, + /// Host-clock offset from the session's clock handshake — added to the local wall + /// clock to express paintable-set time in the host's capture clock (present latency). + pub clock_offset_ns: i64, + /// Controller escape chord — leave fullscreen + release capture. + pub escape_rx: async_channel::Receiver<()>, + /// Escape chord held past the hold threshold — end the session. + pub disconnect_rx: async_channel::Receiver<()>, + pub stop: Arc, + /// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured. + pub inhibit_shortcuts: bool, + /// Show the stats OSD initially (Settings); Ctrl+Alt+Shift+S toggles it live. + pub show_stats: bool, + pub title: String, +} + fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) { let _ = connector.send_input(&InputEvent { kind, @@ -77,12 +111,26 @@ struct Capture { hint: gtk::Label, inhibit_shortcuts: bool, captured: Cell, + /// Newest absolute pointer position not yet on the wire. Motion events only store + /// here; a frame-clock tick flushes at most one `MouseMoveAbs` per tick (a 1000 Hz + /// mouse would otherwise send a datagram — and take the connector's mode lock — per + /// event). Button/scroll/key sends flush it first so they land at the latest + /// position. This client has no relative-motion capture to coalesce — absolute only + /// (pointer-lock is the stage-2 presenter's job). + pending_abs: Cell>, /// VKs / GameStream button ids currently held — flushed up on release. held_keys: RefCell>, held_buttons: RefCell>, } impl Capture { + /// Send the coalesced pointer position, if any — one datagram, one fresh mode read. + fn flush_pending_motion(&self) { + if let Some((x, y)) = self.pending_abs.take() { + send_abs(&self.overlay, &self.connector, x, y); + } + } + fn engage(&self) { if self.captured.replace(true) { return; @@ -107,6 +155,7 @@ impl Capture { } self.overlay.set_cursor(None); self.hint.set_visible(true); + self.pending_abs.set(None); // never flush motion gathered while captured if let Some(tl) = self .window .surface() @@ -124,17 +173,72 @@ impl Capture { } } -#[allow(clippy::too_many_lines, clippy::too_many_arguments)] -pub fn new( - window: &adw::ApplicationWindow, - connector: Arc, - frames: async_channel::Receiver, - escape_rx: async_channel::Receiver<()>, - disconnect_rx: async_channel::Receiver<()>, - stop: Arc, - inhibit_shortcuts: bool, - title: &str, -) -> StreamPage { +pub fn new(args: StreamPageArgs) -> StreamPage { + let StreamPageArgs { + window, + connector, + frames, + clock_offset_ns, + escape_rx, + disconnect_rx, + stop, + inhibit_shortcuts, + show_stats, + title, + } = args; + let w = build_widgets(&window, &title); + w.stats_label.set_visible(show_stats); + + let capture = Rc::new(Capture { + connector, + window: window.clone(), + overlay: w.overlay.clone(), + hint: w.hint.clone(), + inhibit_shortcuts, + captured: Cell::new(false), + pending_abs: Cell::new(None), + held_keys: RefCell::new(HashSet::new()), + held_buttons: RefCell::new(HashSet::new()), + }); + + let present_ms = Rc::new(Cell::new(0.0f32)); + spawn_frame_consumer(&w.picture, frames, clock_offset_ns, present_ms.clone()); + attach_keyboard(&w.overlay, &window, &capture, &stop, &w.stats_label); + attach_mouse(&w.overlay, &capture); + attach_scroll(&w.overlay, &capture); + let active_handler = attach_capture_lifecycle(&w.overlay, &window, &capture); + let escape_future = spawn_escape_watch(&window, &capture, escape_rx); + let disconnect_future = spawn_disconnect_watch(&window, &capture, &stop, disconnect_rx); + wire_teardown( + &w.page, + &window, + &stop, + (w.fs_handler, active_handler), + escape_future, + disconnect_future, + ); + + StreamPage { + page: w.page, + stats_label: w.stats_label, + present_ms, + } +} + +/// The page's widget tree, built in one place so `new` reads as assembly. +struct PageWidgets { + picture: gtk::Picture, + stats_label: gtk::Label, + hint: gtk::Label, + overlay: gtk::Overlay, + page: adw::NavigationPage, + /// Fullscreen-notify handler on the shared window — disconnected on page teardown. + fs_handler: glib::SignalHandlerId, +} + +/// The offloaded picture under an overlay (stats HUD, capture hint, fullscreen hint), a +/// header bar with the fullscreen toggle, and the window's fullscreen behavior. +fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets { let picture = gtk::Picture::new(); picture.set_content_fit(gtk::ContentFit::Contain); @@ -153,7 +257,7 @@ pub fn new( stats_label.set_margin_top(12); let hint = gtk::Label::new(Some( - "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects", + "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats", )); hint.add_css_class("osd"); hint.set_halign(gtk::Align::Center); @@ -180,17 +284,6 @@ pub fn new( overlay.add_overlay(&fs_hint); overlay.set_focusable(true); - let capture = Rc::new(Capture { - connector: connector.clone(), - window: window.clone(), - overlay: overlay.clone(), - hint: hint.clone(), - inhibit_shortcuts, - captured: Cell::new(false), - held_keys: RefCell::new(HashSet::new()), - held_buttons: RefCell::new(HashSet::new()), - }); - let header = adw::HeaderBar::new(); let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic"); fullscreen_btn.set_tooltip_text(Some("Fullscreen (F11)")); @@ -233,270 +326,379 @@ pub fn new( .child(&toolbar) .build(); - // --- Frame consumer: newest texture wins, set on the GTK frame clock's cadence. --- - { - let picture = picture.downgrade(); - // The host encodes BT.709 limited-range; without an explicit color state GDK - // would convert NV12 dmabufs with the (BT.601) dmabuf default. - let rec709 = { - let cicp = gdk::CicpParams::new(); - cicp.set_color_primaries(1); - cicp.set_transfer_function(1); - cicp.set_matrix_coefficients(1); - cicp.set_range(gdk::CicpRange::Narrow); - cicp.build_color_state().ok() - }; - glib::spawn_future_local(async move { - while let Ok(f) = frames.recv().await { - let Some(picture) = picture.upgrade() else { - break; - }; - match f { - DecodedFrame::Cpu(c) => { - let bytes = glib::Bytes::from_owned(c.rgba); - let tex = gdk::MemoryTexture::new( - c.width as i32, - c.height as i32, - gdk::MemoryFormat::R8g8b8a8, - &bytes, - c.stride, - ); - picture.set_paintable(Some(&tex)); + PageWidgets { + picture, + stats_label, + hint, + overlay, + page, + fs_handler, + } +} + +/// Frame consumer: each decoded frame becomes the picture's paintable as soon as it +/// arrives (the session's tiny `force_send` queue already dropped anything older); GTK +/// then draws whatever paintable is current on its own frame clock. Ends itself when the +/// channel closes or the picture is gone. +/// +/// Also the capture→present-ish measurement point: at each paintable set the frame's +/// host capture pts is compared against the local wall clock expressed in the host clock +/// (`clock_offset_ns`, same math as the session's decode latency). This is +/// capture→paintable-SET — GTK's own present adds one compositor cycle after this. The +/// 1 s p50 lands on the stats OSD (via `present_ms`) and in a "present window" debug +/// line for headless validation. +fn spawn_frame_consumer( + picture: >k::Picture, + frames: async_channel::Receiver, + clock_offset_ns: i64, + present_ms: Rc>, +) { + let picture = picture.downgrade(); + // The host encodes BT.709 limited-range; without an explicit color state GDK + // would convert NV12 dmabufs with the (BT.601) dmabuf default. + let rec709 = { + let cicp = gdk::CicpParams::new(); + cicp.set_color_primaries(1); + cicp.set_transfer_function(1); + cicp.set_matrix_coefficients(1); + cicp.set_range(gdk::CicpRange::Narrow); + cicp.build_color_state().ok() + }; + glib::spawn_future_local(async move { + let mut win_lat_us: Vec = Vec::with_capacity(256); + let mut win_start = Instant::now(); + while let Ok(f) = frames.recv().await { + let Some(picture) = picture.upgrade() else { + break; + }; + let mut presented = false; + match f.image { + DecodedImage::Cpu(c) => { + let bytes = glib::Bytes::from_owned(c.rgba); + let tex = gdk::MemoryTexture::new( + c.width as i32, + c.height as i32, + gdk::MemoryFormat::R8g8b8a8, + &bytes, + c.stride, + ); + picture.set_paintable(Some(&tex)); + presented = true; + } + DecodedImage::Dmabuf(d) => { + let mut b = gdk::DmabufTextureBuilder::new() + .set_display(&picture.display()) + .set_width(d.width) + .set_height(d.height) + .set_fourcc(d.fourcc) + .set_modifier(d.modifier) + .set_n_planes(d.planes.len() as u32) + .set_color_state(rec709.as_ref()); + for (i, p) in d.planes.iter().enumerate() { + b = unsafe { b.set_fd(i as u32, p.fd) } + .set_offset(i as u32, p.offset) + .set_stride(i as u32, p.stride); } - DecodedFrame::Dmabuf(d) => { - let mut b = gdk::DmabufTextureBuilder::new() - .set_display(&picture.display()) - .set_width(d.width) - .set_height(d.height) - .set_fourcc(d.fourcc) - .set_modifier(d.modifier) - .set_n_planes(d.planes.len() as u32) - .set_color_state(rec709.as_ref()); - for (i, p) in d.planes.iter().enumerate() { - b = unsafe { b.set_fd(i as u32, p.fd) } - .set_offset(i as u32, p.offset) - .set_stride(i as u32, p.stride); + let guard = d.guard; + // GDK runs the release func whether the import succeeds or not. + match unsafe { b.build_with_release_func(move || drop(guard)) } { + Ok(tex) => { + picture.set_paintable(Some(&tex)); + presented = true; } - let guard = d.guard; - // GDK runs the release func whether the import succeeds or not. - match unsafe { b.build_with_release_func(move || drop(guard)) } { - Ok(tex) => picture.set_paintable(Some(&tex)), - Err(e) => { - // Import rejected (format/modifier) — surfaces once per - // session in practice; the stream continues on the next - // frame, and PUNKTFUNK_DECODER=software is the escape. - tracing::warn!(error = %e, "dmabuf texture import failed"); - } + Err(e) => { + // Import rejected (format/modifier) — surfaces once per + // session in practice; the stream continues on the next + // frame, and PUNKTFUNK_DECODER=software is the escape. + tracing::warn!(error = %e, "dmabuf texture import failed"); } } } } - }); - } + // Capture→paintable-set latency, host-clock corrected (same math and sanity + // bound as the session's decode-latency window). + if presented { + let lat = (crate::session::now_ns() as i128 + clock_offset_ns as i128 + - f.pts_ns as i128) + .max(0) as u64; + if lat > 0 && lat < 10_000_000_000 { + win_lat_us.push(lat / 1000); + } + } + if win_start.elapsed() >= Duration::from_secs(1) { + win_lat_us.sort_unstable(); + let p50 = win_lat_us.get(win_lat_us.len() / 2).copied().unwrap_or(0); + tracing::debug!( + frames = win_lat_us.len(), + present_p50_us = p50, + "present window" + ); + present_ms.set(p50 as f32 / 1000.0); + win_lat_us.clear(); + win_start = Instant::now(); + } + } + }); +} - // --- Keyboard --- - { - let key = gtk::EventControllerKey::new(); - key.set_propagation_phase(gtk::PropagationPhase::Capture); - let cap = capture.clone(); - let window_k = window.clone(); - let stop_kb = stop.clone(); - key.connect_key_pressed(move |_, keyval, keycode, state| { - let chord = gdk::ModifierType::CONTROL_MASK - | gdk::ModifierType::ALT_MASK - | gdk::ModifierType::SHIFT_MASK; - if state.contains(chord) && keyval.to_lower() == gdk::Key::q { - if cap.captured.get() { - cap.release(); - } else { - cap.engage(); - } - return glib::Propagation::Stop; - } - // Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host, - // the capture toggle alone can't end a stream, so this is the keyboard's explicit exit. - if state.contains(chord) && keyval.to_lower() == gdk::Key::d { - cap.release(); - stop_kb.store(true, Ordering::SeqCst); - return glib::Propagation::Stop; - } - if keyval == gdk::Key::F11 { - if window_k.is_fullscreen() { - window_k.unfullscreen(); - } else { - window_k.fullscreen(); - } - return glib::Propagation::Stop; - } - if !cap.captured.get() { - return glib::Propagation::Proceed; - } - if let Some(vk) = keycode - .checked_sub(8) - .and_then(|c| keymap::evdev_to_vk(c as u16)) - { - cap.held_keys.borrow_mut().insert(vk); - send(&cap.connector, InputKind::KeyDown, vk as u32, 0, 0, 0); - } - glib::Propagation::Stop - }); - let cap = capture.clone(); - key.connect_key_released(move |_, _keyval, keycode, _state| { - if let Some(vk) = keycode - .checked_sub(8) - .and_then(|c| keymap::evdev_to_vk(c as u16)) - { - // Flush-on-release may have beaten us to it — only forward if still held. - if cap.held_keys.borrow_mut().remove(&vk) { - send(&cap.connector, InputKind::KeyUp, vk as u32, 0, 0, 0); - } - } - }); - overlay.add_controller(key); - } - - // --- Mouse: absolute motion, buttons, wheel — forwarded only while captured --- - { - let motion = gtk::EventControllerMotion::new(); - let cap = capture.clone(); - motion.connect_motion(move |_, x, y| { +/// Keyboard, capture-phase: the release (Ctrl+Alt+Shift+Q) / disconnect (Ctrl+Alt+Shift+D) +/// / stats (Ctrl+Alt+Shift+S) chords and F11 are handled locally; everything else becomes +/// a VK on the wire while captured. +fn attach_keyboard( + overlay: >k::Overlay, + window: &adw::ApplicationWindow, + capture: &Rc, + stop: &Arc, + stats: >k::Label, +) { + let key = gtk::EventControllerKey::new(); + key.set_propagation_phase(gtk::PropagationPhase::Capture); + let cap = capture.clone(); + let window_k = window.clone(); + let stop_kb = stop.clone(); + let stats = stats.clone(); + key.connect_key_pressed(move |_, keyval, keycode, state| { + let chord = gdk::ModifierType::CONTROL_MASK + | gdk::ModifierType::ALT_MASK + | gdk::ModifierType::SHIFT_MASK; + if state.contains(chord) && keyval.to_lower() == gdk::Key::q { if cap.captured.get() { - send_abs(&cap.overlay, &cap.connector, x, y); + cap.release(); + } else { + cap.engage(); } - }); - overlay.add_controller(motion); - } - { - let click = gtk::GestureClick::builder().button(0).build(); - let cap = capture.clone(); - click.connect_pressed(move |g, _n, x, y| { - cap.overlay.grab_focus(); - if !cap.captured.get() { - cap.engage(); // the engaging click is suppressed toward the host - return; + return glib::Propagation::Stop; + } + // Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host, + // the capture toggle alone can't end a stream, so this is the keyboard's explicit exit. + if state.contains(chord) && keyval.to_lower() == gdk::Key::d { + cap.release(); + stop_kb.store(true, Ordering::SeqCst); + return glib::Propagation::Stop; + } + // Ctrl+Alt+Shift+S — toggle the stats OSD live (initial state = Settings). + if state.contains(chord) && keyval.to_lower() == gdk::Key::s { + stats.set_visible(!stats.is_visible()); + return glib::Propagation::Stop; + } + if keyval == gdk::Key::F11 { + if window_k.is_fullscreen() { + window_k.unfullscreen(); + } else { + window_k.fullscreen(); } - send_abs(&cap.overlay, &cap.connector, x, y); - if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) { - cap.held_buttons.borrow_mut().insert(gs); - send(&cap.connector, InputKind::MouseButtonDown, gs, 0, 0, 0); + return glib::Propagation::Stop; + } + if !cap.captured.get() { + return glib::Propagation::Proceed; + } + if let Some(vk) = keycode + .checked_sub(8) + .and_then(|c| keymap::evdev_to_vk(c as u16)) + { + // Keep the wire ordered: the host must see the cursor where the user does + // when the key lands (e.g. "press E at the crosshair"). + cap.flush_pending_motion(); + cap.held_keys.borrow_mut().insert(vk); + send(&cap.connector, InputKind::KeyDown, vk as u32, 0, 0, 0); + } + glib::Propagation::Stop + }); + let cap = capture.clone(); + key.connect_key_released(move |_, _keyval, keycode, _state| { + if let Some(vk) = keycode + .checked_sub(8) + .and_then(|c| keymap::evdev_to_vk(c as u16)) + { + // Flush-on-release may have beaten us to it — only forward if still held. + if cap.held_keys.borrow_mut().remove(&vk) { + send(&cap.connector, InputKind::KeyUp, vk as u32, 0, 0, 0); } - }); - let cap = capture.clone(); - click.connect_released(move |g, _n, _x, _y| { - if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) { - if cap.held_buttons.borrow_mut().remove(&gs) { - send(&cap.connector, InputKind::MouseButtonUp, gs, 0, 0, 0); - } - } - }); - overlay.add_controller(click); - } - { - let scroll = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::BOTH_AXES); - let cap = capture.clone(); - scroll.connect_scroll(move |_, dx, dy| { - if !cap.captured.get() { - return glib::Propagation::Proceed; - } - // The wire carries WHEEL_DELTA(120) units, positive = up / right; GTK's dy is - // positive = down. Smooth fractions survive — libei's discrete scroll is - // 120-based too. - let vy = (-dy * 120.0) as i32; - if vy != 0 { - send(&cap.connector, InputKind::MouseScroll, 0, vy, 0, 0); - } - let vx = (dx * 120.0) as i32; - if vx != 0 { - send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0); - } - glib::Propagation::Stop - }); - overlay.add_controller(scroll); - } + } + }); + overlay.add_controller(key); +} - // --- Capture lifecycle --- +/// Mouse: absolute motion + buttons — forwarded only while captured; the click that +/// engages capture is suppressed toward the host. Motion is COALESCED: each event only +/// stores the newest position; the overlay's frame-clock tick flushes at most one +/// `MouseMoveAbs` per tick (the paintable set on every stream frame keeps the clock +/// ticking while streaming). Buttons flush the pending position first so a click lands +/// exactly where the cursor last was. +fn attach_mouse(overlay: >k::Overlay, capture: &Rc) { + let motion = gtk::EventControllerMotion::new(); + let cap = capture.clone(); + motion.connect_motion(move |_, x, y| { + if cap.captured.get() { + cap.pending_abs.set(Some((x, y))); + } + }); + overlay.add_controller(motion); + + // The per-tick flush. (The tick callback dies with the overlay, so no teardown.) + let cap = capture.clone(); + overlay.add_tick_callback(move |_, _| { + cap.flush_pending_motion(); + glib::ControlFlow::Continue + }); + + let click = gtk::GestureClick::builder().button(0).build(); + let cap = capture.clone(); + click.connect_pressed(move |g, _n, x, y| { + cap.overlay.grab_focus(); + if !cap.captured.get() { + cap.engage(); // the engaging click is suppressed toward the host + return; + } + // The click's own coordinates are the freshest position — supersede any pending + // motion, then flush so the button-down lands there. + cap.pending_abs.set(Some((x, y))); + cap.flush_pending_motion(); + if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) { + cap.held_buttons.borrow_mut().insert(gs); + send(&cap.connector, InputKind::MouseButtonDown, gs, 0, 0, 0); + } + }); + let cap = capture.clone(); + click.connect_released(move |g, _n, _x, _y| { + cap.flush_pending_motion(); // the release must not beat the motion before it + if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) { + if cap.held_buttons.borrow_mut().remove(&gs) { + send(&cap.connector, InputKind::MouseButtonUp, gs, 0, 0, 0); + } + } + }); + overlay.add_controller(click); +} + +/// Wheel — forwarded only while captured. +fn attach_scroll(overlay: >k::Overlay, capture: &Rc) { + let scroll = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::BOTH_AXES); + let cap = capture.clone(); + scroll.connect_scroll(move |_, dx, dy| { + if !cap.captured.get() { + return glib::Propagation::Proceed; + } + cap.flush_pending_motion(); // scroll happens at the latest cursor position + // The wire carries WHEEL_DELTA(120) units, positive = up / right; GTK's dy is + // positive = down. Smooth fractions survive — libei's discrete scroll is + // 120-based too. + let vy = (-dy * 120.0) as i32; + if vy != 0 { + send(&cap.connector, InputKind::MouseScroll, 0, vy, 0, 0); + } + let vx = (dx * 120.0) as i32; + if vx != 0 { + send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0); + } + glib::Propagation::Stop + }); + overlay.add_controller(scroll); +} + +/// Capture lifecycle: engaged when the page maps (the stream just started — trust is +/// already confirmed by then), released on focus loss (Alt-Tab away, another window — +/// Swift does the same) and on unmap. Returns the window-level focus handler for +/// teardown (the window outlives the page). +fn attach_capture_lifecycle( + overlay: >k::Overlay, + window: &adw::ApplicationWindow, + capture: &Rc, +) -> glib::SignalHandlerId { { - // Engaged when the stream starts (trust is already confirmed by then). let cap = capture.clone(); overlay.connect_map(move |w| { w.grab_focus(); cap.engage(); }); } - // Focus loss releases (Alt-Tab away, another window) — Swift does the same. - let active_handler = { - let cap = capture.clone(); - window.connect_is_active_notify(move |w| { - if !w.is_active() { - cap.release(); - } - }) - }; { let cap = capture.clone(); overlay.connect_unmap(move |_| cap.release()); } + let cap = capture.clone(); + window.connect_is_active_notify(move |w| { + if !w.is_active() { + cap.release(); + } + }) +} - // Controller escape chord (gamepad service) → leave fullscreen + release capture. The - // chord is the only fullscreen exit a controller has (no F11 key; fullscreen hides the - // chrome). Aborted on page-hidden so a stale future can't act on the shared window. - let escape_future = { - let window = window.clone(); - let cap = capture.clone(); - glib::spawn_future_local(async move { - while escape_rx.recv().await.is_ok() { - if window.is_fullscreen() { - window.unfullscreen(); - } - cap.release(); - } - }) - }; - - // Controller disconnect (escape chord held past the hold threshold) → end the session, the - // controller equivalent of Ctrl+Alt+Shift+D. Setting `stop` ends the session pump, which pops - // this page (and fires `hidden` below). One-shot — the session is going away. - let disconnect_future = { - let window = window.clone(); - let cap = capture.clone(); - let stop_d = stop.clone(); - glib::spawn_future_local(async move { - if disconnect_rx.recv().await.is_ok() { - cap.release(); - if window.is_fullscreen() { - window.unfullscreen(); - } - stop_d.store(true, Ordering::SeqCst); - } - }) - }; - - // The page's `hidden` fires once navigation away completes (back button, pop on - // session end) — NOT on the transient unmap/map cycle a NavigationView push performs. - { - let window = window.clone(); - let stop_h = stop.clone(); - let handlers = RefCell::new(Some((fs_handler, active_handler))); - let escape_future = RefCell::new(Some(escape_future)); - let disconnect_future = RefCell::new(Some(disconnect_future)); - page.connect_hidden(move |_| { - tracing::debug!("stream page hidden — ending session"); - if let Some((fs, active)) = handlers.borrow_mut().take() { - window.disconnect(fs); - window.disconnect(active); - } - if let Some(f) = escape_future.borrow_mut().take() { - f.abort(); - } - if let Some(f) = disconnect_future.borrow_mut().take() { - f.abort(); - } +/// Controller escape chord (gamepad service) → leave fullscreen + release capture. The +/// chord is the only fullscreen exit a controller has (no F11 key; fullscreen hides the +/// chrome). Aborted on page-hidden so a stale future can't act on the shared window. +fn spawn_escape_watch( + window: &adw::ApplicationWindow, + capture: &Rc, + escape_rx: async_channel::Receiver<()>, +) -> glib::JoinHandle<()> { + let window = window.clone(); + let cap = capture.clone(); + glib::spawn_future_local(async move { + while escape_rx.recv().await.is_ok() { if window.is_fullscreen() { window.unfullscreen(); } - stop_h.store(true, Ordering::SeqCst); - }); - } - - StreamPage { page, stats_label } + cap.release(); + } + }) +} + +/// Controller disconnect (escape chord held past the hold threshold) → end the session, +/// the controller equivalent of Ctrl+Alt+Shift+D. Setting `stop` ends the session pump, +/// which pops this page (and fires `hidden` — see `wire_teardown`). One-shot — the +/// session is going away. +fn spawn_disconnect_watch( + window: &adw::ApplicationWindow, + capture: &Rc, + stop: &Arc, + disconnect_rx: async_channel::Receiver<()>, +) -> glib::JoinHandle<()> { + let window = window.clone(); + let cap = capture.clone(); + let stop_d = stop.clone(); + glib::spawn_future_local(async move { + if disconnect_rx.recv().await.is_ok() { + cap.release(); + if window.is_fullscreen() { + window.unfullscreen(); + } + stop_d.store(true, Ordering::SeqCst); + } + }) +} + +/// The page's `hidden` fires once navigation away completes (back button, pop on +/// session end) — NOT on the transient unmap/map cycle a NavigationView push performs: +/// disconnect the window-level handlers, abort the chord futures, and stop the session. +fn wire_teardown( + page: &adw::NavigationPage, + window: &adw::ApplicationWindow, + stop: &Arc, + handlers: (glib::SignalHandlerId, glib::SignalHandlerId), + escape_future: glib::JoinHandle<()>, + disconnect_future: glib::JoinHandle<()>, +) { + let window = window.clone(); + let stop_h = stop.clone(); + let handlers = RefCell::new(Some(handlers)); + let escape_future = RefCell::new(Some(escape_future)); + let disconnect_future = RefCell::new(Some(disconnect_future)); + page.connect_hidden(move |_| { + tracing::debug!("stream page hidden — ending session"); + if let Some((fs, active)) = handlers.borrow_mut().take() { + window.disconnect(fs); + window.disconnect(active); + } + if let Some(f) = escape_future.borrow_mut().take() { + f.abort(); + } + if let Some(f) = disconnect_future.borrow_mut().take() { + f.abort(); + } + if window.is_fullscreen() { + window.unfullscreen(); + } + stop_h.store(true, Ordering::SeqCst); + }); } diff --git a/clients/linux/src/ui_trust.rs b/clients/linux/src/ui_trust.rs new file mode 100644 index 0000000..385ffbb --- /dev/null +++ b/clients/linux/src/ui_trust.rs @@ -0,0 +1,266 @@ +//! The trust gate and dialogs in front of every connect: TOFU, the SPAKE2 PIN ceremony, +//! and delegated (request-access) approval. + +use crate::app::App; +use crate::launch::{start_session, start_session_with, StartOpts}; +use crate::trust; +use crate::ui_hosts::ConnectRequest; +use adw::prelude::*; +use gtk::glib; +use std::rc::Rc; + +/// The trust gate in front of every connect. The host is the policy authority (it +/// advertises `pair=optional` only when it accepts unpaired clients); the client renders +/// its trust UI from that: +/// 1. PINNED RECONNECT — a host already pinned to this exact fingerprint connects silently. +/// 2. FINGERPRINT CHANGED — a host we know at this address but whose fingerprint no longer +/// matches is the impostor signal: force re-pairing via the PIN ceremony, regardless of +/// the advertised policy. +/// 3. NEW host — TOFU is offered only when the host advertised `pair=optional` (rule 3a); +/// otherwise (pair=required, unknown/empty policy, or a manual entry) PIN pairing is +/// mandatory (rule 3b). +/// +/// A new host is never auto-connected without a stored pin or an explicit trust decision. +pub fn initiate_connect(app: Rc, req: ConnectRequest) { + if app.busy.get() { + return; + } + let known = trust::KnownHosts::load(); + match &req.fp_hex { + Some(fp_hex) => { + if known.find_by_fp(fp_hex).is_some() { + // Rule 1: pinned fingerprint matches — silent connect. + start_session(app, req.clone(), trust::parse_hex32(fp_hex)); + } else if known.find_by_addr(&req.addr, req.port).is_some() { + // Rule 2: we trust a host at this address but the fingerprint changed — + // the impostor signal. Re-pair via the PIN ceremony (no TOFU shortcut). + app.toast("Host fingerprint changed — re-pair with a PIN to continue"); + pin_dialog(app, req); + } else if req.pair_optional { + // Rule 3a: the host opted into reduced-security TOFU; offer it alongside PIN. + tofu_dialog(app, req); + } else { + // Rule 3b: pair=required or unknown policy — offer no-PIN delegated approval + // (request access → approve in the console) or the PIN ceremony. + approval_dialog(app, req); + } + } + None => { + // Manual entry (no advertised fingerprint). A known address connects silently + // on its stored pin (rule 1); an unknown one must pair — request access (approve in + // the console) or use a PIN; never silent TOFU. + match known + .find_by_addr(&req.addr, req.port) + .and_then(|k| trust::parse_hex32(&k.fp_hex)) + { + Some(pin) => start_session(app, req, Some(pin)), + None => approval_dialog(app, req), // rule 3b + } + } + } +} + +/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines +/// (the Apple TrustCardView format), far easier to compare against the host's log than +/// one 64-char run. +fn grouped_fingerprint(fp: &str) -> String { + let groups: Vec<&str> = fp + .as_bytes() + .chunks(4) + .map(|c| std::str::from_utf8(c).unwrap_or("")) + .collect(); + groups + .chunks(8) + .map(|line| line.join(" ")) + .collect::>() + .join("\n") +} + +/// First contact with a discovered host: show the advertised fingerprint and let the user +/// trust it (TOFU), run the PIN ceremony instead, or walk away. +pub fn tofu_dialog(app: Rc, req: ConnectRequest) { + let fp = req.fp_hex.clone().unwrap_or_default(); + let dialog = adw::AlertDialog::new( + Some("New Host"), + Some(&format!( + "{} at {}:{}\n\nPairing with a PIN verifies the certificate fingerprint below; \ + trusting accepts it as-is.", + req.name, req.addr, req.port + )), + ); + let fp_label = gtk::Label::new(Some(&grouped_fingerprint(&fp))); + fp_label.add_css_class("monospace"); + fp_label.set_selectable(true); + fp_label.set_justify(gtk::Justification::Center); + fp_label.set_halign(gtk::Align::Center); + dialog.set_extra_child(Some(&fp_label)); + dialog.add_responses(&[ + ("cancel", "Cancel"), + ("pair", "Pair with PIN…"), + ("trust", "Trust & Connect"), + ]); + dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("trust")); + dialog.set_close_response("cancel"); + let parent = app.window.clone(); + dialog.connect_response(None, move |_, response| match response { + "trust" => { + trust::persist_host(&req.name, &req.addr, req.port, &fp, false); + start_session(app.clone(), req.clone(), trust::parse_hex32(&fp)); + } + "pair" => pin_dialog(app.clone(), req.clone()), + _ => {} + }); + dialog.present(Some(&parent)); +} + +/// The SPAKE2 ceremony: the host is armed and displays a 4-digit PIN; proving knowledge +/// of it pins the host's certificate (and registers ours) with no offline-guessable +/// transcript. +pub fn pin_dialog(app: Rc, req: ConnectRequest) { + let entry = gtk::Entry::builder() + .input_purpose(gtk::InputPurpose::Digits) + .placeholder_text("4-digit PIN shown by the host") + .activates_default(true) + .build(); + // The label the HOST stores this client under (its paired-devices list) — prefilled + // with the machine hostname, editable (the Apple pair sheet does the same). + let name_entry = gtk::Entry::builder() + .text(glib::host_name().as_str()) + .activates_default(true) + .build(); + let name_caption = gtk::Label::new(Some("This device")); + name_caption.add_css_class("caption"); + name_caption.add_css_class("dim-label"); + name_caption.set_halign(gtk::Align::Start); + let fields = gtk::Box::new(gtk::Orientation::Vertical, 6); + fields.append(&name_caption); + fields.append(&name_entry); + let pin_caption = gtk::Label::new(Some("PIN")); + pin_caption.add_css_class("caption"); + pin_caption.add_css_class("dim-label"); + pin_caption.set_halign(gtk::Align::Start); + fields.append(&pin_caption); + fields.append(&entry); + let dialog = adw::AlertDialog::new( + Some("Pair with PIN"), + Some(&format!( + "Arm pairing on {} (console or web UI), then enter the PIN it displays.", + req.name + )), + ); + dialog.set_extra_child(Some(&fields)); + dialog.add_responses(&[("cancel", "Cancel"), ("pair", "Pair")]); + dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("pair")); + dialog.set_close_response("cancel"); + let parent = app.window.clone(); + dialog.connect_response(Some("pair"), move |_, _| { + let pin = entry.text().to_string(); + let app = app.clone(); + let req = req.clone(); + let identity = app.identity.clone(); + let (tx, rx) = async_channel::bounded::>(1); + let device = name_entry.text().trim().to_string(); + let name = if device.is_empty() { + glib::host_name().to_string() + } else { + device + }; + let (host, port) = (req.addr.clone(), req.port); + std::thread::spawn(move || { + let result = trust::pair_with_host(&host, port, &identity, &pin, &name) + .map_err(|e| format!("Pairing failed: {e:?} (wrong PIN, or pairing not armed?)")); + let _ = tx.send_blocking(result); + }); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(fp)) => { + trust::persist_host(&req.name, &req.addr, req.port, &trust::hex(&fp), true); + app.toast("Paired — connecting…"); + start_session(app.clone(), req, Some(fp)); + } + Ok(Err(msg)) => app.toast(&msg), + Err(_) => {} + } + }); + }); + dialog.present(Some(&parent)); +} + +/// A fresh host that requires pairing: offer the two ways in. "Request access" is the no-PIN +/// path — connect and wait for the operator to click Approve in the host's console/web UI +/// (delegated approval); "Use a PIN instead…" runs the SPAKE2 ceremony. +fn approval_dialog(app: Rc, req: ConnectRequest) { + let dialog = adw::AlertDialog::new( + Some("Pairing Required"), + Some(&format!( + "{} requires pairing.\n\nRequest access and approve this device in the host's console \ + (or web UI) — no PIN needed. Or pair with the 4-digit PIN it can display.", + req.name + )), + ); + dialog.add_responses(&[ + ("cancel", "Cancel"), + ("pin", "Use a PIN instead…"), + ("request", "Request Access"), + ]); + dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("request")); + dialog.set_close_response("cancel"); + let parent = app.window.clone(); + dialog.connect_response(None, move |_, response| match response { + "request" => request_access(app.clone(), req.clone()), + "pin" => pin_dialog(app.clone(), req.clone()), + _ => {} + }); + dialog.present(Some(&parent)); +} + +/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the +/// operator approves it in the console, showing a cancelable "waiting" dialog meanwhile. On +/// approval the same connection is admitted (no reconnect) and the host is saved as paired. +fn request_access(app: Rc, req: ConnectRequest) { + // Pin the advertised certificate for a discovered host (defence against a host impostor while + // we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use. + let pin = req.fp_hex.as_deref().and_then(trust::parse_hex32); + let cancel = Rc::new(std::cell::Cell::new(false)); + + let waiting = adw::AlertDialog::new( + Some("Waiting for Approval"), + Some(&format!( + "Approve “{}” in {}’s console or web UI.\n\nThis device is waiting to be let in — it \ + connects automatically once you approve it.", + glib::host_name(), + req.name + )), + ); + waiting.add_responses(&[("cancel", "Cancel")]); + waiting.set_close_response("cancel"); + { + let app = app.clone(); + let cancel = cancel.clone(); + waiting.connect_response(Some("cancel"), move |_, _| { + // Return the UI immediately; the in-flight connect is left to time out and is torn + // down silently by the event loop (see StartOpts::cancel). + cancel.set(true); + app.busy.set(false); + app.toast("Cancelled — the request may still be pending on the host."); + }); + } + waiting.present(Some(&app.window)); + + start_session_with( + app, + req, + pin, + StartOpts { + // Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator + // approval still lands on this connection rather than timing the client out first. + connect_timeout: std::time::Duration::from_secs(185), + persist_paired: true, + waiting: Some(waiting), + cancel: Some(cancel), + }, + ); +} diff --git a/clients/linux/src/video.rs b/clients/linux/src/video.rs index 7f449f5..c6a93bd 100644 --- a/clients/linux/src/video.rs +++ b/clients/linux/src/video.rs @@ -23,7 +23,16 @@ use ffmpeg_next as ffmpeg; use std::os::fd::RawFd; use std::ptr; -pub enum DecodedFrame { +/// One decoded frame headed for the presenter, carrying the host capture timestamp so the +/// UI can measure capture→paintable-set latency at the moment it presents. +pub struct DecodedFrame { + /// Host-clock capture pts (ns) of the AU this image decoded from — compare against + /// the local wall clock + `clock_offset_ns` at paintable-set time. + pub pts_ns: u64, + pub image: DecodedImage, +} + +pub enum DecodedImage { Cpu(CpuFrame), Dmabuf(DmabufFrame), } @@ -108,9 +117,17 @@ pub fn decodable_codecs() -> u8 { } impl Decoder { - pub fn new(codec_id: ffmpeg::codec::Id) -> Result { + /// `codec_id` is the codec the host resolved in the Welcome (never assume HEVC). + /// `pref` is the Settings "Video decoder" value (`auto`/`vaapi`/`software`). + /// Precedence: the `PUNKTFUNK_DECODER` env override wins (support/debug escape + /// hatch, and the documented knob), then the setting; both default to auto + /// (VAAPI → software). + pub fn new(codec_id: ffmpeg::codec::Id, pref: &str) -> Result { ffmpeg::init().context("ffmpeg init")?; - let choice = std::env::var("PUNKTFUNK_DECODER").unwrap_or_default(); + let choice = std::env::var("PUNKTFUNK_DECODER") + .ok() + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| pref.to_string()); if choice != "software" { match VaapiDecoder::new(codec_id) { Ok(v) => { @@ -138,17 +155,17 @@ impl Decoder { /// one-in/one-out). A software decode error after packet loss is survivable — log /// upstream and keep feeding. A VAAPI error demotes to software for the rest of the /// session (broken driver, e.g. nvidia-vaapi-driver) — the next IDR resynchronizes. - pub fn decode(&mut self, au: &[u8]) -> Result> { + pub fn decode(&mut self, au: &[u8]) -> Result> { match &mut self.backend { Backend::Vaapi(v) => match v.decode(au) { - Ok(f) => Ok(f.map(DecodedFrame::Dmabuf)), + Ok(f) => Ok(f.map(DecodedImage::Dmabuf)), Err(e) => { tracing::warn!(error = %e, "VAAPI decode failed — falling back to software"); self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?); Ok(None) } }, - Backend::Software(s) => Ok(s.decode(au)?.map(DecodedFrame::Cpu)), + Backend::Software(s) => Ok(s.decode(au)?.map(DecodedImage::Cpu)), } } } @@ -219,13 +236,60 @@ impl SoftwareDecoder { self.sws = Some((ctx, fmt, w, h)); } let (sws, ..) = self.sws.as_mut().unwrap(); - let mut rgba = AvFrame::empty(); - sws.run(frame, &mut rgba).map_err(|e| anyhow!("sws: {e}"))?; + // Single-pass conversion: swscale writes straight into the Vec the texture will + // wrap. (The old path scaled into a scratch AVFrame and then copied `data(0)` out + // — a second full-frame pass per frame.) 64-byte row alignment keeps swscale on + // aligned SIMD stores; `GdkMemoryTexture` takes the resulting stride explicitly. + const ALIGN: i32 = 64; + use ffmpeg::ffi; + let dst_fmt = ffi::AVPixelFormat::AV_PIX_FMT_RGBA; + // SAFETY: pure size computation from format/dimensions; no pointers involved. + let size = unsafe { ffi::av_image_get_buffer_size(dst_fmt, w as i32, h as i32, ALIGN) }; + if size < 0 { + return Err(averr("av_image_get_buffer_size", size)); + } + let rgba = vec![0u8; size as usize]; + let mut dst_data: [*mut u8; 4] = [ptr::null_mut(); 4]; + let mut dst_linesize: [i32; 4] = [0; 4]; + // SAFETY: fill_arrays only derives plane pointers/strides into `rgba` (sized by + // av_image_get_buffer_size above, same format/align) — no allocation, no + // ownership transfer; `rgba` outlives the scale below. + let r = unsafe { + ffi::av_image_fill_arrays( + dst_data.as_mut_ptr(), + dst_linesize.as_mut_ptr(), + rgba.as_ptr(), + dst_fmt, + w as i32, + h as i32, + ALIGN, + ) + }; + if r < 0 { + return Err(averr("av_image_fill_arrays", r)); + } + // SAFETY: src pointers/strides belong to the decoder-owned `frame` (alive for the + // call); dst pointers were just filled over `rgba`, and sws_scale writes rows + // [0, h) only — exactly the buffer fill_arrays sized. + let r = unsafe { + ffi::sws_scale( + sws.as_mut_ptr(), + (*frame.as_ptr()).data.as_ptr() as *const *const u8, + (*frame.as_ptr()).linesize.as_ptr(), + 0, + h as i32, + dst_data.as_ptr(), + dst_linesize.as_ptr(), + ) + }; + if r < 0 { + return Err(averr("sws_scale", r)); + } Ok(CpuFrame { width: w, height: h, - stride: rgba.stride(0), - rgba: rgba.data(0).to_vec(), + stride: dst_linesize[0] as usize, + rgba, }) } } diff --git a/clients/linux/tools/screenshots.sh b/clients/linux/tools/screenshots.sh index 311893d..6979e75 100755 --- a/clients/linux/tools/screenshots.sh +++ b/clients/linux/tools/screenshots.sh @@ -1,30 +1,33 @@ #!/usr/bin/env bash -# Capture host-free UI screenshots of the native Linux client under a virtual X -# display. Mirrors the iOS harness (clients/apple/tools/screenshots.sh): one app -# launch per scene (PUNKTFUNK_SHOT_SCENE), the app renders a mock-populated REAL -# view and prints `PF_SHOT_READY`, then we grab the X root window. No host, GPU, or -# live stream — only the chrome scenes (the stream page needs a live connector). +# Capture host-free UI screenshots of the native Linux client. Mirrors the iOS harness +# (clients/apple/tools/screenshots.sh): one app launch per scene (PUNKTFUNK_SHOT_SCENE), +# the app renders a mock-populated REAL view and — when the binary supports it — CAPTURES +# ITSELF (PUNKTFUNK_SHOT_OUT: widget snapshot → gsk render → PNG) before printing +# `PF_SHOT_READY`. Self-capture needs no Xvfb/ImageMagick and runs under a live Wayland +# session too; the X11 root-grab path is kept as a fallback for old binaries. No host, +# GPU, or live stream — only the chrome scenes (the stream page needs a live connector). # # cargo build --release -p punktfunk-client-linux # bash clients/linux/tools/screenshots.sh # → clients/linux/screenshots/.png # bash clients/linux/tools/screenshots.sh hosts pair # a subset # # Env knobs: BIN (client binary), OUT (output dir), GEOMETRY (Xvfb WxHxDepth), -# SETTLE (extra seconds after PF_SHOT_READY), SHOT_DISPLAY (X display), GSK_RENDERER -# (gl|ngl|cairo — gl/llvmpipe by default for full libadwaita fidelity). +# SETTLE (extra seconds after PF_SHOT_READY, X11-fallback only), SHOT_DISPLAY (X display), +# GSK_RENDERER (gl|ngl|cairo — cairo is the safe headless/no-GPU choice), FORCE_XVFB=1 +# (ignore a live Wayland session and go through Xvfb anyway). set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # clients/linux BIN="${BIN:-$here/../../target/release/punktfunk-client}" OUT="${OUT:-$here/screenshots}" -# The client window maps at its 1100x720 default; with no WM under Xvfb it lands at the -# top-left, so keep the root just larger so the full window (incl. its CSD shadow) is -# captured by a root grab with only a thin margin to crop. -GEOMETRY="${GEOMETRY:-1280x800x24}" +# X11 fallback only: the client window maps at its 1200x780 default; with no WM under +# Xvfb it lands at the top-left, so keep the root just larger so the full window (incl. +# its CSD shadow) is captured by a root grab with only a thin margin to crop. +GEOMETRY="${GEOMETRY:-1380x860x24}" SETTLE="${SETTLE:-1.2}" SHOT_DISPLAY="${SHOT_DISPLAY:-:99}" -if [ "$#" -gt 0 ]; then SCENES=("$@"); else SCENES=(hosts settings trust pair); fi +if [ "$#" -gt 0 ]; then SCENES=("$@"); else SCENES=(hosts settings trust pair addhost shortcuts library); fi [ -x "$BIN" ] || { echo "client binary not found: $BIN (build it first: cargo build --release -p punktfunk-client-linux)" >&2 @@ -33,7 +36,8 @@ if [ "$#" -gt 0 ]; then SCENES=("$@"); else SCENES=(hosts settings trust pair); # Isolated scratch HOME: the client generates its identity here on first run, and the # saved-hosts grid is read from client-known-hosts.json, so seed mock hosts for the -# `hosts` scene (the dialogs/settings build their own mock state in-app). +# `hosts` scene (the dialogs/settings build their own mock state in-app). `last_used` +# on the first entry renders the most-recent accent bar. WORK="$(mktemp -d)" export HOME="$WORK" mkdir -p "$HOME/.config/punktfunk" @@ -42,7 +46,7 @@ cat >"$HOME/.config/punktfunk/client-known-hosts.json" <<'JSON' "hosts": [ { "name": "Living Room PC", "addr": "192.168.1.42", "port": 9777, "fp_hex": "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00", - "paired": true }, + "paired": true, "last_used": 1780000000 }, { "name": "Office", "addr": "192.168.1.50", "port": 9777, "fp_hex": "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00", "paired": false } @@ -50,34 +54,45 @@ cat >"$HOME/.config/punktfunk/client-known-hosts.json" <<'JSON' } JSON -# Software-rendered X session — no GPU/Wayland. GL/llvmpipe runs the real NGL renderer -# (cairo is documented-incomplete for 3D-transformed content / libadwaita transitions). -unset WAYLAND_DISPLAY -export DISPLAY="$SHOT_DISPLAY" -export GDK_BACKEND=x11 -export LIBGL_ALWAYS_SOFTWARE=1 -export GALLIUM_DRIVER="${GALLIUM_DRIVER:-llvmpipe}" -export GSK_RENDERER="${GSK_RENDERER:-gl}" - -Xvfb "$SHOT_DISPLAY" -screen 0 "$GEOMETRY" -nolisten tcp >"$WORK/xvfb.log" 2>&1 & -XVFB_PID=$! +XVFB_PID="" cleanup() { - kill "$XVFB_PID" 2>/dev/null || true + if [ -n "$XVFB_PID" ]; then kill "$XVFB_PID" 2>/dev/null || true; fi rm -rf "$WORK" } trap cleanup EXIT -# Wait for the display to accept connections. -for _ in $(seq 1 50); do - if command -v xdpyinfo >/dev/null 2>&1; then - xdpyinfo -display "$SHOT_DISPLAY" >/dev/null 2>&1 && break - else - [ -e "/tmp/.X11-unix/X${SHOT_DISPLAY#:}" ] && break - fi - sleep 0.1 -done +if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -z "${FORCE_XVFB:-}" ]; then + # Live Wayland session: self-capture only (there is no root grab on Wayland). The + # window flashes up briefly per scene — this is a dev harness, not CI polish. + MODE=wayland + export GDK_BACKEND=wayland +else + # Software-rendered X session — no GPU/Wayland needed. GL/llvmpipe runs the real NGL + # renderer (cairo is documented-incomplete for 3D-transformed content / libadwaita + # transitions). + MODE=x11 + unset WAYLAND_DISPLAY + export DISPLAY="$SHOT_DISPLAY" + export GDK_BACKEND=x11 + export LIBGL_ALWAYS_SOFTWARE=1 + export GALLIUM_DRIVER="${GALLIUM_DRIVER:-llvmpipe}" + export GSK_RENDERER="${GSK_RENDERER:-gl}" -capture() { + Xvfb "$SHOT_DISPLAY" -screen 0 "$GEOMETRY" -nolisten tcp >"$WORK/xvfb.log" 2>&1 & + XVFB_PID=$! + # Wait for the display to accept connections. + for _ in $(seq 1 50); do + if command -v xdpyinfo >/dev/null 2>&1; then + xdpyinfo -display "$SHOT_DISPLAY" >/dev/null 2>&1 && break + else + [ -e "/tmp/.X11-unix/X${SHOT_DISPLAY#:}" ] && break + fi + sleep 0.1 + done +fi + +# X11 root grab — the fallback for binaries without self-capture. +capture_x11() { local out="$1" if command -v import >/dev/null 2>&1; then import -silent -window root "$out" @@ -93,7 +108,9 @@ mkdir -p "$OUT" rc=0 for scene in "${SCENES[@]}"; do : >"$WORK/log" - PUNKTFUNK_SHOT_SCENE="$scene" "$BIN" >"$WORK/log" 2>&1 & + rm -f "$OUT/$scene.png" + PUNKTFUNK_SHOT_SCENE="$scene" PUNKTFUNK_SHOT_OUT="$OUT/$scene.png" \ + "$BIN" >"$WORK/log" 2>&1 & pid=$! ready=0 for _ in $(seq 1 200); do # up to ~20s @@ -101,14 +118,27 @@ for scene in "${SCENES[@]}"; do ready=1 break fi - if ! kill -0 "$pid" 2>/dev/null; then break; fi + if ! kill -0 "$pid" 2>/dev/null; then + # Self-capture binaries exit(0) right after READY — check the log once more. + grep -q "PF_SHOT_READY" "$WORK/log" && ready=1 + break + fi sleep 0.1 done if [ "$ready" = 1 ]; then - sleep "$SETTLE" - if capture "$OUT/$scene.png"; then - echo "✓ $scene → $OUT/$scene.png" + if [ -f "$OUT/$scene.png" ]; then + echo "✓ $scene → $OUT/$scene.png (self-capture)" + elif [ "$MODE" = x11 ]; then + # Old binary (no PUNKTFUNK_SHOT_OUT support) — grab the X root instead. + sleep "$SETTLE" + if capture_x11 "$OUT/$scene.png"; then + echo "✓ $scene → $OUT/$scene.png (x11 grab)" + else + rc=1 + fi else + echo "✗ $scene: no PNG (self-capture failed — see log)" >&2 + sed 's/^/ /' "$WORK/log" >&2 || true rc=1 fi else