diff --git a/Cargo.lock b/Cargo.lock index c6f6696f..c70f4575 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1146,6 +1146,9 @@ name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +dependencies = [ + "getrandom 0.3.4", +] [[package]] name = "fec-rs" @@ -1233,6 +1236,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ + "fastrand", "futures-core", "futures-sink", "spin", @@ -1259,6 +1263,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fragile" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" +dependencies = [ + "futures-core", +] + [[package]] name = "fs-err" version = "3.3.0" @@ -2995,10 +3008,10 @@ dependencies = [ "anyhow", "async-channel", "gtk4", - "khronos-egl", "libadwaita", "pf-client-core", "punktfunk-core", + "relm4", "serde_json", "tracing", "tracing-subscriber", @@ -3445,6 +3458,41 @@ dependencies = [ "tokio", ] +[[package]] +name = "relm4" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6420f090f0545e9ec9656469d139a4e1b66ff9c30b808fe2247892724f71a198" +dependencies = [ + "flume", + "fragile", + "futures", + "gtk4", + "libadwaita", + "once_cell", + "relm4-css", + "relm4-macros", + "tokio", + "tracing", +] + +[[package]] +name = "relm4-css" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3b81d263f784b103c815afa29124486b59741eca069ce7a5999efb14f13c368" + +[[package]] +name = "relm4-macros" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c9dbf50a60c82375e66b61d522c936b187a11b25c0a42e91c516326ad24a4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ring" version = "0.17.14" diff --git a/clients/linux-session/src/main.rs b/clients/linux-session/src/main.rs index 2d7e5a98..44327545 100644 --- a/clients/linux-session/src/main.rs +++ b/clients/linux-session/src/main.rs @@ -63,6 +63,17 @@ mod session_main { } } + /// The connect budget: 15 s normally; `--connect-timeout SECS` overrides — the + /// shell's request-access flow passes ~185 s because the host PARKS the connection + /// until the operator clicks Approve. + pub(crate) fn connect_timeout() -> Duration { + Duration::from_secs( + arg_value("--connect-timeout") + .and_then(|v| v.parse().ok()) + .unwrap_or(15), + ) + } + /// One session's pump parameters from the Settings store — shared by `--connect` /// and every `--browse` launch. Explicit settings, `0` fields resolved to the /// window's display (the GTK client reads the monitor under its window — same @@ -117,7 +128,7 @@ mod session_main { launch, pin: Some(pin), identity, - connect_timeout: Duration::from_secs(15), + connect_timeout: connect_timeout(), force_software, } } diff --git a/clients/linux/Cargo.toml b/clients/linux/Cargo.toml index a4a72b3a..8a613486 100644 --- a/clients/linux/Cargo.toml +++ b/clients/linux/Cargo.toml @@ -27,11 +27,8 @@ gtk = { package = "gtk4", version = "0.11", features = ["v4_16"] } adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] } async-channel = "2" -# The VAAPI GL presenter (video_gl.rs): EGL dmabuf import into a GDK-shared context, dlopened -# at runtime (`dynamic`) so GPU-less boxes and the software path never touch libEGL. -khronos-egl = { version = "6", features = ["dynamic"] } - serde_json = "1" anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +relm4 = { version = "0.11", features = ["libadwaita"] } diff --git a/clients/linux/README.md b/clients/linux/README.md index c25b5d43..5e06107e 100644 --- a/clients/linux/README.md +++ b/clients/linux/README.md @@ -57,32 +57,28 @@ cargo run -p punktfunk-client-linux -- --connect HOST[:PORT] # skip the host l cargo run -p punktfunk-client-linux -- --browse HOST # the gamepad library launcher ``` -The binary is named **`punktfunk-client`**. Handy flags: `--connect host[:port]` (start a session -immediately — for scripting and the Steam Deck launcher) with optional `--launch ` (ask the -host to launch that library title, id from `--library`), `--browse host[:port]` (the gamepad -library launcher; `--mgmt ` overrides the management port it fetches from), -`--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`; `PUNKTFUNK_FAKE_LIBRARY=` feeds the launcher -canned entries for UI work with no host. +The binary is named **`punktfunk-client`** — the relm4/libadwaita desktop shell (hosts, +pairing/trust, settings, the desktop library page). Every stream and the console game +library run in the sibling **`punktfunk-session`** Vulkan binary; the shell spawns it +for connects, and `--connect`/`--browse` on the shell exec it directly (so the Decky +wrapper keeps working unchanged). Headless flags stay in the shell: +`--pair --connect host[:port]` (pairing ceremony), `--wake host[:port]`, and +`--library host[:mgmt_port]` (print a host's game library). ## Layout ``` src/ - main.rs · app.rs entry point, GTK application, primary menu, CSS - cli.rs CLI paths (--connect/--launch, --browse, headless --pair, screenshot scenes) - ui_hosts.rs host card grids (saved + discovered) · add-host dialog · banner + main.rs · app.rs entry point, relm4 AppModel (window, trust gate, session child + lifecycle, typed messages), primary menu, CSS + cli.rs headless paths (--pair/--wake/--library), the --connect/--browse + exec handoff to punktfunk-session, screenshot scenes + ui_hosts.rs hosts page component (FactoryVecDeque cards, saved + discovered + grids, add-host dialog, banner) ui_library.rs game-library poster grid (per-host, launches titles) - ui_gamepad_library.rs the --browse gamepad launcher (aurora · coverflow · hint bar) ui_trust.rs TOFU / PIN-pairing / request-access dialogs ui_settings.rs resolution · refresh · decoder · bitrate · compositor · mic - ui_stream.rs the stream window (GtkGraphicsOffload present) + input capture - launch.rs session launch/UI glue over the shared session pump - spawn.rs desktop connects → the punktfunk-session Vulkan binary - (PUNKTFUNK_LEGACY_PRESENTER=1 keeps them in-process) - video_gl.rs VAAPI dmabuf → RGBA GL presenter (EGL import, CICP-driven CSC; - the legacy/fallback presenter) + spawn.rs the session-child plumbing (stdout contract → AppMsg) tools/screenshots.sh store screenshot capture (app self-capture; Xvfb fallback) ``` diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index b2e1c185..65dec580 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -1,17 +1,22 @@ -//! 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`. +//! The application shell as a relm4 component tree (phase 5 of punktfunk-planning +//! `linux-client-rearchitecture.md`): [`AppModel`] owns the window, navigation, trust +//! gate, and the spawned session child's lifecycle; the hosts page is a child component +//! ([`crate::ui_hosts`]); dialogs (trust, settings, library) are plain GTK invoked from +//! `update`. Every stream runs in the `punktfunk-session` Vulkan binary — the shell +//! never touches video. -use crate::trust::Settings; -use crate::ui_hosts::{ConnectRequest, HostsCallbacks, HostsUi}; +use crate::spawn::{self, SpawnOpts}; +use crate::trust::{self, Settings}; +use crate::ui_hosts::{ConnectRequest, HostsMsg, HostsOutput, HostsPage}; use adw::prelude::*; use gtk::{gdk, gio, glib}; use punktfunk_core::client::NativeClient; use punktfunk_core::config::{CompositorPref, GamepadPref}; +use relm4::prelude::*; use std::cell::RefCell; use std::rc::Rc; -const APP_ID: &str = "io.unom.Punktfunk"; +pub const APP_ID: &str = "io.unom.Punktfunk"; /// 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 @@ -34,80 +39,460 @@ const CSS: &str = " .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); } -/* Gaming-Mode launches: gamescope displays the window fullscreen but never ACKs the - xdg_toplevel fullscreen state, so GTK keeps the floating-CSD styling — libadwaita's - rounded corners + shadow margin stay visible over the stream. Flatten them outright. */ -window.pf-chromeless { border-radius: 0; box-shadow: none; } -/* The gamepad library launcher (`--browse`, ui_gamepad_library) — always-dark console - chrome over the aurora, independent of the desktop theme. */ -.pf-gl-page { background: black; color: white; } -.pf-gl-host { font-size: 1.15em; font-weight: bold; color: rgba(255, 255, 255, 0.9); } -.pf-gl-chip { font-size: 0.8em; color: rgba(255, 255, 255, 0.7); - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 999px; padding: 4px 12px; } -/* Solid face, not glass: coverflow side cards OVERLAP — a translucent card would bleed - the stack through the one on top. */ -.pf-gl-poster { border-radius: 16px; background: rgb(30, 30, 37); - border: 1px solid rgba(255, 255, 255, 0.07); } -.pf-gl-dim { background: black; border-radius: 16px; } -.pf-gl-detail-title { font-size: 1.7em; font-weight: bold; color: white; } -.pf-gl-detail-store { font-size: 0.75em; font-weight: 600; letter-spacing: 2px; - color: rgba(255, 255, 255, 0.5); } -.pf-gl-glyph { font-size: 0.85em; font-weight: bold; color: white; - background: rgba(255, 255, 255, 0.14); - border-radius: 999px; min-width: 26px; min-height: 26px; padding: 2px 8px; } -.pf-gl-hint { color: rgba(255, 255, 255, 0.85); } -.pf-gl-status { font-size: 0.85em; color: #ff938a; } -.pf-gl-error-title { font-size: 1.4em; font-weight: bold; color: white; } "; -pub struct App { +/// Everything the shell shares below the component tree. +pub struct AppModel { pub window: adw::ApplicationWindow, pub nav: adw::NavigationView, - pub toasts: adw::ToastOverlay, + toasts: adw::ToastOverlay, pub settings: Rc>, pub identity: (String, String), - /// App-lifetime SDL gamepad service: Settings list + per-session capture/feedback. + /// App-lifetime SDL gamepad service (Settings' controller list + pinning). Streams + /// run in the session binary, which has its own. pub gamepad: crate::gamepad::GamepadService, - /// One session at a time — ignore connects while one is starting/running. - pub busy: std::cell::Cell, - /// Steam Deck / Gaming-Mode launch: fullscreen the window (chrome-less) when a stream starts. - pub fullscreen: bool, - /// Quit when the session ends (Gaming-Mode `--connect` launch): the app IS the stream — - /// exiting ends the Steam "game" so the Deck returns to Gaming Mode instead of stranding - /// the user on the client's own hosts page. - pub quit_on_session_end: bool, - /// The hosts page handle (banner + per-card connecting spinner), set right after the - /// page is built — `None` only during construction. - pub hosts: RefCell>>, - /// The gamepad library launcher — `Some` only under `--browse`, where it replaces the - /// hosts page as the root (and session end returns here instead of quitting). - pub browse: RefCell>>, + hosts: Controller, + /// One session child at a time — connects while one runs are ignored. + busy: bool, + /// The request-access "waiting for approval" dialog, closed on the first child + /// event. A shared slot (not a message): dialogs are main-thread GTK objects and + /// `AppMsg` must stay `Send` for the session child's reader thread. + waiting: Rc>>, } -impl App { +#[derive(Debug)] +pub enum AppMsg { + /// The trust gate in front of every connect (rules 1–3, see `update`). + Connect(ConnectRequest), + /// Wake an offline saved host, poll until it advertises, then `Connect`. + WakeConnect(ConnectRequest), + /// The SPAKE2 PIN ceremony dialog. + Pair(ConnectRequest), + SpeedTest(ConnectRequest), + /// The desktop library page (mgmt port from the live advert when known). + OpenLibrary(ConnectRequest, Option), + /// Spawn the session child now (trust already decided; `tofu` = persist the + /// fingerprint once the child proves it). + StartSession { + req: ConnectRequest, + fp_hex: String, + tofu: bool, + opts: SpawnOpts, + }, + /// The child presented its first frame. + SessionReady { + req: ConnectRequest, + fp_hex: String, + tofu: bool, + persist_paired: bool, + }, + /// The child exited (the session is over, or the connect failed). + SessionExited { + req: ConnectRequest, + code: i32, + error: Option<(String, bool)>, + ended: Option, + tofu: bool, + }, + /// Request-access Cancel: the child was killed; release busy quietly. + CancelPending, + /// The speed-test dialog resolved (either way) — release `busy`. + SpeedTestDone, + ShowPreferences, + ShowShortcuts, + ShowAbout, + ShowAddHost, + Toast(String), +} + +pub struct AppInit { + pub gamepad: crate::gamepad::GamepadService, +} + +pub struct AppWidgets {} + +impl SimpleComponent for AppModel { + type Init = AppInit; + type Input = AppMsg; + type Output = (); + type Root = adw::ApplicationWindow; + type Widgets = AppWidgets; + + fn init_root() -> Self::Root { + adw::ApplicationWindow::builder() + .title("Punktfunk") + .default_width(1200) + .default_height(780) + .build() + } + + fn init( + init: Self::Init, + window: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let identity = match trust::load_or_create_identity() { + Ok(i) => i, + Err(e) => { + tracing::error!("client identity: {e:#}"); + std::process::exit(1); + } + }; + load_css(); + // Screenshot scenes must capture settled frames: kill every GTK/libadwaita + // animation (a headless session may starve the frame clock and leave a + // transition frozen mid-flight in the capture). + if crate::cli::shot_scene().is_some() { + if let Some(s) = gtk::Settings::default() { + s.set_gtk_enable_animations(false); + } + } + + let settings = Rc::new(RefCell::new(Settings::load())); + // Re-apply the persisted forwarded-controller pin (stable key; the service + // matches it whenever such a pad connects). + { + let forward = settings.borrow().forward_pad.clone(); + if !forward.is_empty() { + init.gamepad.set_pinned(Some(forward)); + } + } + + let hosts = HostsPage::builder() + .launch(settings.clone()) + .forward(sender.input_sender(), |out| match out { + HostsOutput::Connect(req) => AppMsg::Connect(req), + HostsOutput::WakeConnect(req) => AppMsg::WakeConnect(req), + HostsOutput::Pair(req) => AppMsg::Pair(req), + HostsOutput::SpeedTest(req) => AppMsg::SpeedTest(req), + HostsOutput::Library(req, mgmt) => AppMsg::OpenLibrary(req, mgmt), + }); + + let nav = adw::NavigationView::new(); + nav.add(hosts.widget()); + let toasts = adw::ToastOverlay::new(); + toasts.set_child(Some(&nav)); + window.set_content(Some(&toasts)); + // Gaming-mode fallback (a bare launch under gamescope): fullscreen the shell. + if crate::cli::fullscreen_mode() { + window.fullscreen(); + } + + let model = AppModel { + window: window.clone(), + nav, + toasts, + settings, + identity, + gamepad: init.gamepad, + hosts, + busy: false, + waiting: Rc::new(RefCell::new(None)), + }; + install_actions(&model.window, &sender); + + // CI screenshot mode: dispatch the scripted scene once the window is actually + // mapped (AdwDialogs need a live window; relm4 maps it after `init` returns, so + // this can't run inline like the pre-relm4 `activate` path did). + if let Some(scene) = crate::cli::shot_scene() { + let ctx = crate::cli::ShotCtx { + window: model.window.clone(), + nav: model.nav.clone(), + hosts: model.hosts.sender().clone(), + settings: model.settings.clone(), + gamepad: model.gamepad.clone(), + identity: model.identity.clone(), + sender: sender.clone(), + }; + let fired = std::cell::Cell::new(false); + model.window.connect_map(move |_| { + if fired.replace(true) { + return; // map can fire more than once; the scene runs on the first + } + crate::cli::run_shot(&ctx, &scene); + }); + } + window.present(); + + ComponentParts { + model, + widgets: AppWidgets {}, + } + } + + fn update(&mut self, msg: AppMsg, sender: ComponentSender) { + match msg { + // The trust gate (the host is the policy authority — it advertises + // `pair=optional` only when it accepts unpaired clients): + // 1. PINNED RECONNECT — a stored fingerprint connects silently. + // 2. FINGERPRINT CHANGED — known address, different fp: the impostor + // signal; force the PIN ceremony. + // 3a. NEW + pair=optional — offer TOFU alongside PIN. + // 3b. NEW otherwise — delegated approval (request access) or PIN. + AppMsg::Connect(req) => { + if self.busy { + return; + } + let known = trust::KnownHosts::load(); + match &req.fp_hex { + Some(fp_hex) => { + if known.find_by_fp(fp_hex).is_some() { + let fp_hex = fp_hex.clone(); + sender.input(AppMsg::StartSession { + req, + fp_hex, + tofu: false, + opts: SpawnOpts::default(), + }); + } else if known.find_by_addr(&req.addr, req.port).is_some() { + self.toast("Host fingerprint changed — re-pair with a PIN to continue"); + crate::ui_trust::pin_dialog(&self.window, &sender, self.identity.clone(), req); + } else if req.pair_optional { + crate::ui_trust::tofu_dialog(&self.window, &sender, req); + } else { + crate::ui_trust::approval_dialog( + &self.window, + &sender, + self.waiting.clone(), + req, + ); + } + } + None => { + // Manual entry: a known address connects on its stored pin; + // an unknown one must pair — never silent TOFU. + match known.find_by_addr(&req.addr, req.port).map(|k| k.fp_hex.clone()) { + Some(fp_hex) => sender.input(AppMsg::StartSession { + req, + fp_hex, + tofu: false, + opts: SpawnOpts::default(), + }), + None => crate::ui_trust::approval_dialog( + &self.window, + &sender, + self.waiting.clone(), + req, + ), + } + } + } + } + AppMsg::WakeConnect(req) => { + if !self.busy { + crate::ui_trust::wake_and_connect(&self.window, &sender, req); + } + } + AppMsg::Pair(req) => { + if !self.busy { + crate::ui_trust::pin_dialog(&self.window, &sender, self.identity.clone(), req); + } + } + AppMsg::SpeedTest(req) => self.speed_test(req, &sender), + AppMsg::SpeedTestDone => self.busy = false, + AppMsg::OpenLibrary(req, mgmt_port) => { + crate::ui_library::open(self, &sender, req, mgmt_port); + } + AppMsg::StartSession { + req, + fp_hex, + tofu, + opts, + } => { + if std::mem::replace(&mut self.busy, true) { + return; + } + self.hosts.emit(HostsMsg::ClearError); + self.hosts.emit(HostsMsg::SetConnecting(Some(req.card_key()))); + let fullscreen = self.settings.borrow().fullscreen_on_stream; + if let Err(e) = spawn::spawn_session( + sender.input_sender().clone(), + req, + fp_hex, + tofu, + fullscreen, + opts, + ) { + self.busy = false; + self.hosts.emit(HostsMsg::SetConnecting(None)); + self.hosts.emit(HostsMsg::ShowError(e)); + } + } + AppMsg::SessionReady { + req, + fp_hex, + tofu, + persist_paired, + } => { + self.close_waiting(); + self.hosts.emit(HostsMsg::SetConnecting(None)); + if persist_paired { + // Request-access: the operator approved this device — a trusted + // PAIRED host from now on, like after a PIN ceremony. + trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, true); + self.toast("Approved — connected"); + } else if tofu { + // The advertised fingerprint proved itself on a real connect. + trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, false); + self.toast(&format!( + "Trusted on first use — fingerprint {}…", + &fp_hex[..16.min(fp_hex.len())] + )); + } + self.hosts.emit(HostsMsg::Refresh); + } + AppMsg::SessionExited { + req, + code, + error, + ended, + tofu, + } => { + self.close_waiting(); + self.busy = false; + self.hosts.emit(HostsMsg::SetConnecting(None)); + match (code, error, ended) { + (0, _, None) => {} // clean end — back on the hosts page, no noise + (0, _, Some(reason)) => self.hosts.emit(HostsMsg::ShowError(reason)), + (_, Some((_, true)), _) if !tofu => { + // The stored pin no longer matches (rotated cert or impostor). + self.toast("Host fingerprint changed — re-pair with a PIN to continue"); + crate::ui_trust::pin_dialog(&self.window, &sender, self.identity.clone(), req); + } + (_, Some((msg, _)), _) => self + .hosts + .emit(HostsMsg::ShowError(format!("Couldn't connect — {msg}"))), + (-1, None, _) => {} // killed (request-access cancel) — already handled + (code, None, _) => self.hosts.emit(HostsMsg::ShowError(format!( + "Stream session failed (punktfunk-session exit {code})" + ))), + } + } + AppMsg::CancelPending => { + self.close_waiting(); + self.busy = false; + self.hosts.emit(HostsMsg::SetConnecting(None)); + self.toast("Cancelled — the request may still be pending on the host."); + } + AppMsg::ShowPreferences => { + let hosts = self.hosts.sender().clone(); + crate::ui_settings::show(&self.window, self.settings.clone(), &self.gamepad, move || { + // The library toggle changes the saved cards' menu — re-render. + let _ = hosts.send(HostsMsg::Refresh); + }); + } + AppMsg::ShowShortcuts => shortcuts_window(&self.window).present(), + AppMsg::ShowAbout => crate::ui_settings::show_about(&self.window), + AppMsg::ShowAddHost => self.hosts.emit(HostsMsg::ShowAddHost), + AppMsg::Toast(msg) => self.toast(&msg), + } + } +} + +impl AppModel { pub fn toast(&self, msg: &str) { self.toasts.add_toast(adw::Toast::new(msg)); } - pub fn hosts_ui(&self) -> Option> { - self.hosts.borrow().clone() - } - - pub fn browse_ui(&self) -> Option> { - self.browse.borrow().clone() - } - - /// Surface a connect failure: the launcher in browse mode, else the hosts page banner - /// (toast fallback pre-build). - pub fn connect_error(&self, msg: &str) { - match (self.browse_ui(), self.hosts_ui()) { - (Some(l), _) => l.show_error(msg), - (_, Some(h)) => h.show_error(msg), - _ => self.toast(msg), + fn close_waiting(&mut self) { + if let Some(w) = self.waiting.borrow_mut().take() { + w.close(); } } + + /// Measure the path to a host over the real data plane: connect, burst probe filler + /// for 2 s, report goodput · loss · a recommended bitrate, and apply it in one tap. + fn speed_test(&mut self, req: ConnectRequest, sender: &ComponentSender) { + if std::mem::replace(&mut self.busy, true) { + return; + } + let pin = req.fp_hex.as_deref().and_then(trust::parse_hex32); + let status = gtk::Label::new(Some("Connecting…")); + let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name)); + dialog.set_extra_child(Some(&status)); + dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]); + dialog.set_response_enabled("apply", false); + dialog.set_close_response("close"); + dialog.present(Some(&self.window)); + + let (tx, rx) = + async_channel::bounded::>(1); + let identity = self.identity.clone(); + let (host, port) = (req.addr.clone(), req.port); + std::thread::spawn(move || { + let result = (|| { + let c = NativeClient::connect( + &host, + port, + punktfunk_core::config::Mode { + width: 1280, + height: 720, + refresh_hz: 60, + }, + CompositorPref::Auto, + GamepadPref::Auto, + 0, // bitrate_kbps (host default) + 0, // video_caps: probe connect, nothing presents + 2, // audio_channels: stereo + crate::video::decodable_codecs(), // codecs (unused by the probe, but honest) + 0, // preferred_codec: no preference + None, // launch: probe connect, no game + pin, + Some(identity), + std::time::Duration::from_secs(15), + ) + .map_err(|e| format!("connect: {e:?}"))?; + c.request_probe(3_000_000, 2_000) + .map_err(|e| format!("probe: {e:?}"))?; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + std::thread::sleep(std::time::Duration::from_millis(250)); + let r = c.probe_result(); + if r.done { + // Let the last UDP shards land before tearing down. + std::thread::sleep(std::time::Duration::from_millis(400)); + return Ok(c.probe_result()); + } + if std::time::Instant::now() > deadline { + return Err("probe timed out".to_string()); + } + } + })(); + let _ = tx.send_blocking(result); + }); + + let settings = self.settings.clone(); + let toasts = self.toasts.clone(); + let sender = sender.clone(); + glib::spawn_future_local(async move { + let outcome = rx.recv().await; + sender.input(AppMsg::SpeedTestDone); + match outcome { + Ok(Ok(r)) => { + let mbps = f64::from(r.throughput_kbps) / 1000.0; + let recommended_kbps = r.throughput_kbps / 10 * 7; + status.set_text(&format!( + "{mbps:.0} Mbit/s measured · {:.1} % loss\nRecommended bitrate: {:.0} Mbit/s", + r.loss_pct, + f64::from(recommended_kbps) / 1000.0, + )); + dialog.set_response_enabled("apply", true); + dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested); + dialog.connect_response(Some("apply"), move |_, _| { + let mut s = settings.borrow_mut(); + s.bitrate_kbps = recommended_kbps; + s.save(); + toasts.add_toast(adw::Toast::new(&format!( + "Bitrate set to {:.0} Mbit/s", + f64::from(recommended_kbps) / 1000.0 + ))); + }); + } + Ok(Err(msg)) => status.set_text(&msg), + Err(_) => {} + } + }); + } } pub fn run() -> glib::ExitCode { @@ -117,13 +502,8 @@ pub fn run() -> glib::ExitCode { ) .init(); // Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming every - // physical pad Steam Input has virtualized — SDL then hides the real device so games - // only see the virtual X360 pad. Right for games, wrong for us: capturing the Deck's - // built-in controller (trackpads/paddles/gyro, 28DE:1205) needs SDL's HIDAPI driver - // to enumerate the REAL device, and the built-in pad can never leave Steam Input - // ("Steam Controller" is always-required), so this filter is the only off switch we - // get. Clear it while still single-threaded (the gamepad worker starts with the UI); - // we dedupe the virtual pad ourselves (`gamepad.rs` `active_id` skips steam_virtual). + // physical pad Steam Input has virtualized; the Settings controller list needs the + // real devices (same rationale as the session binary). for var in [ "SDL_GAMECONTROLLER_IGNORE_DEVICES", "SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT", @@ -133,160 +513,36 @@ pub fn run() -> glib::ExitCode { std::env::remove_var(var); } } - // 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. + // Headless paths (no GTK window). 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); } - // Headless Wake-on-LAN (no GTK window): `--wake host[:port]`. The Decky wrapper calls this - // before the stream launch so a sleeping host is up by the time `--connect` runs. if crate::cli::arg_value("--wake").is_some() { return crate::cli::cli_wake(); } - 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 crate::cli::shot_scene().is_some() { - builder = builder.flags(gtk::gio::ApplicationFlags::NON_UNIQUE); - } - let app = builder.build(); - // One SDL context for the whole process: `activate` fires again on every subsequent - // launch forwarded to this already-running singleton (another `--connect`, the desktop - // icon clicked twice, …). SDL only ever lets the FIRST thread that calls `sdl3::init()` - // hold the "main thread" — a second `GamepadService::start()` from a later `activate` - // would spawn a new thread that fails that check forever. Starting it once here and - // cloning it into each `build_ui` keeps the worker thread (and its pad state) shared - // across every window instead. - let gamepad = crate::gamepad::GamepadService::start(); - app.connect_activate(move |gtk_app| build_ui(gtk_app, gamepad.clone())); - // GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also - // keeps GApplication from rejecting unknown options. - app.run_with_args(&[] as &[&str]) -} - -fn build_ui(gtk_app: &adw::Application, gamepad: crate::gamepad::GamepadService) { - let identity = match crate::trust::load_or_create_identity() { - Ok(i) => i, - Err(e) => { - tracing::error!("client identity: {e:#}"); - std::process::exit(1); - } - }; - load_css(); - // Screenshot scenes must capture settled frames: kill every GTK/libadwaita animation - // (nav-push slides especially — a headless session may starve the frame clock and - // leave a transition frozen mid-flight in the capture). - if crate::cli::shot_scene().is_some() { - if let Some(s) = gtk::Settings::default() { - s.set_gtk_enable_animations(false); - } - } - - let nav = adw::NavigationView::new(); - let toasts = adw::ToastOverlay::new(); - toasts.set_child(Some(&nav)); - let window = adw::ApplicationWindow::builder() - .application(gtk_app) - .title("Punktfunk") - .default_width(1200) - .default_height(780) - .content(&toasts) - .build(); - - let fullscreen = crate::cli::fullscreen_mode(); - if fullscreen { - // Chrome-less shell: no CSD rounding/shadow (see CSS — gamescope never ACKs the - // fullscreen state, so GTK would keep them), and ask for fullscreen up front. - window.add_css_class("pf-chromeless"); - window.fullscreen(); - } - - let app = Rc::new(App { - window: window.clone(), - nav: nav.clone(), - toasts, - settings: Rc::new(RefCell::new(Settings::load())), - identity, - gamepad, - busy: std::cell::Cell::new(false), - fullscreen, - // (`--browse` makes cli_connect_request None — browse mode returns to the - // launcher on session end instead of quitting.) - quit_on_session_end: fullscreen && crate::cli::cli_connect_request().is_some(), - hosts: RefCell::new(None), - browse: RefCell::new(None), - }); - - // Re-apply the persisted forwarded-controller pin (stable key; the service matches it - // whenever such a pad connects) — without this the pin silently resets to Automatic on - // every launch, and Automatic may resolve to a gyro-less pad (Steam's virtual gamepad). + // Streams and the console library live in the session binary now — exec it, + // forwarding the relevant argv (the Decky wrapper keeps working through the shell + // until it's repointed). + if crate::cli::arg_value("--connect").is_some() || crate::cli::arg_value("--browse").is_some() { - let forward = app.settings.borrow().forward_pad.clone(); - if !forward.is_empty() { - app.gamepad.set_pinned(Some(forward)); - } + return crate::cli::exec_session(); } - // Browse mode (`--browse host`): the app IS the gamepad library launcher — it becomes - // the ONE root page. No hosts page (whose construction starts the mDNS browse), no - // header-menu actions; `Settings::library_enabled` is deliberately ignored (the flag - // gates the desktop menu item — asking to browse IS the opt-in here). - if let Some((req, paired, mgmt_port)) = crate::cli::cli_browse_request() { - let launcher = crate::ui_gamepad_library::open(app.clone(), req, paired, mgmt_port); - nav.add(&launcher.page); - *app.browse.borrow_mut() = Some(launcher); - window.present(); - return; - } - - let hosts_ui = Rc::new(crate::ui_hosts::new( - app.settings.clone(), - HostsCallbacks { - on_connect: { - let app = app.clone(); - Rc::new(move |req| crate::ui_trust::initiate_connect(app.clone(), req)) - }, - on_wake_connect: { - let app = app.clone(); - Rc::new(move |req| crate::ui_trust::wake_and_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)) - }, - }, - )); - *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) = crate::cli::shot_scene() { - crate::cli::run_shot(app, &scene); - return; - } - - if let Some(req) = crate::cli::cli_connect_request() { - crate::ui_trust::initiate_connect(app, req); + 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. + if crate::cli::shot_scene().is_some() { + builder = builder.flags(gio::ApplicationFlags::NON_UNIQUE); } + let adw_app = builder.build(); + // One SDL context for the whole process, started while single-threaded. + let gamepad = crate::gamepad::GamepadService::start(); + let app = relm4::RelmApp::from_app(adw_app).with_args(Vec::new()); + app.run::(AppInit { gamepad }); + glib::ExitCode::SUCCESS } fn load_css() { @@ -301,54 +557,23 @@ fn load_css() { } } -/// 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| { +/// Window actions behind the hosts page's header (the primary menu + "+") — thin +/// forwards into the message loop. +fn install_actions(window: &adw::ApplicationWindow, sender: &ComponentSender) { + let add = |name: &str, msg: fn() -> AppMsg| { let action = gio::SimpleAction::new(name, None); - action.connect_activate(move |_, _| f()); - app.window.add_action(&action); + let sender = sender.clone(); + action.connect_activate(move |_, _| sender.input(msg())); + action }; - { - let app = app.clone(); - 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) - }), - ); - } - { - 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())); - } + window.add_action(&add("preferences", || AppMsg::ShowPreferences)); + window.add_action(&add("shortcuts", || AppMsg::ShowShortcuts)); + window.add_action(&add("about", || AppMsg::ShowAbout)); + window.add_action(&add("add-host", || AppMsg::ShowAddHost)); } -/// The Keyboard Shortcuts window (menu + the shortcuts scene). GtkShortcutsWindow is -/// builder-XML-first, so it's assembled from a snippet rather than widget calls. +/// The Keyboard Shortcuts window — the SESSION window's keys (the shell itself has +/// none); kept here as discoverable documentation. pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow { const UI: &str = r#" @@ -358,7 +583,7 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow - Stream + Stream (session window) Toggle fullscreen @@ -397,97 +622,3 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow window.set_transient_for(Some(parent)); window } - -/// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"): -/// connect, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, report -/// goodput · loss · a recommended bitrate (≈70 % of measured), and apply it in one tap. -fn speed_test(app: Rc, req: ConnectRequest) { - if app.busy.replace(true) { - return; - } - let pin = req.fp_hex.as_deref().and_then(crate::trust::parse_hex32); - let status = gtk::Label::new(Some("Connecting…")); - let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name)); - dialog.set_extra_child(Some(&status)); - dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]); - dialog.set_response_enabled("apply", false); - dialog.set_close_response("close"); - dialog.present(Some(&app.window)); - - let (tx, rx) = - async_channel::bounded::>(1); - let identity = app.identity.clone(); - let (host, port) = (req.addr.clone(), req.port); - std::thread::spawn(move || { - let result = (|| { - let c = NativeClient::connect( - &host, - port, - punktfunk_core::config::Mode { - width: 1280, - height: 720, - refresh_hz: 60, - }, - CompositorPref::Auto, - GamepadPref::Auto, - 0, // bitrate_kbps (host default) - 0, // video_caps: the Linux client has no 10-bit/HDR present path yet - 2, // audio_channels: speed-test probe, stereo - crate::video::decodable_codecs(), // codecs (unused by the probe, but honest) - 0, // preferred_codec: no preference for a speed-test probe - None, // launch: speed-test probe connect, no game - pin, - Some(identity), - std::time::Duration::from_secs(15), - ) - .map_err(|e| format!("connect: {e:?}"))?; - c.request_probe(3_000_000, 2_000) - .map_err(|e| format!("probe: {e:?}"))?; - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - loop { - std::thread::sleep(std::time::Duration::from_millis(250)); - let r = c.probe_result(); - if r.done { - // Let the last UDP shards land before tearing down. - std::thread::sleep(std::time::Duration::from_millis(400)); - return Ok(c.probe_result()); - } - if std::time::Instant::now() > deadline { - return Err("probe timed out".to_string()); - } - } - })(); - let _ = tx.send_blocking(result); - }); - - glib::spawn_future_local(async move { - let outcome = rx.recv().await; - app.busy.set(false); - match outcome { - Ok(Ok(r)) => { - let mbps = f64::from(r.throughput_kbps) / 1000.0; - let recommended_kbps = r.throughput_kbps / 10 * 7; - status.set_text(&format!( - "{mbps:.0} Mbit/s measured · {:.1} % loss\nRecommended bitrate: {:.0} Mbit/s", - r.loss_pct, - f64::from(recommended_kbps) / 1000.0, - )); - dialog.set_response_enabled("apply", true); - dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested); - let settings = app.settings.clone(); - let toasts = app.toasts.clone(); - dialog.connect_response(Some("apply"), move |_, _| { - let mut s = settings.borrow_mut(); - s.bitrate_kbps = recommended_kbps; - s.save(); - toasts.add_toast(adw::Toast::new(&format!( - "Bitrate set to {:.0} Mbit/s", - f64::from(recommended_kbps) / 1000.0 - ))); - }); - } - Ok(Err(msg)) => status.set_text(&msg), - Err(_) => {} - } - }); -} diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index f608261c..12bb5674 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -1,12 +1,27 @@ -//! Command-line entry paths: argv helpers, headless pairing, `--connect`, and the CI -//! screenshot scenes. +//! Command-line entry paths: argv helpers, the headless flows (pair/wake/library), the +//! exec handoff to `punktfunk-session` for `--connect`/`--browse`, and the CI screenshot +//! scenes. -use crate::app::App; -use crate::ui_hosts::ConnectRequest; +use crate::app::AppModel; +use crate::ui_hosts::{ConnectRequest, HostsMsg}; use gtk::glib; use gtk::prelude::*; +use relm4::prelude::*; +use std::cell::RefCell; use std::rc::Rc; +/// The handles `run_shot` needs — cloned out of `AppModel` before it moves into the +/// component parts, so the scene can be dispatched from the window's `map` callback. +pub struct ShotCtx { + pub window: adw::ApplicationWindow, + pub nav: adw::NavigationView, + pub hosts: relm4::Sender, + pub settings: Rc>, + pub gamepad: crate::gamepad::GamepadService, + pub identity: (String, String), + pub sender: ComponentSender, +} + /// The value following `flag` in argv, if present (`--flag value`). pub fn arg_value(flag: &str) -> Option { std::env::args() @@ -20,9 +35,8 @@ 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. +/// Fullscreen the shell — the Gaming-Mode fallback for a bare launch (streams and the +/// console library exec the session binary, which handles its own fullscreen). pub fn fullscreen_mode() -> bool { arg_flag("--fullscreen") || std::env::var_os("SteamDeck").is_some() @@ -38,9 +52,42 @@ fn parse_host_port(target: &str) -> (String, Option) { } } +/// `--connect` / `--browse`: streams and the console library live in the +/// `punktfunk-session` Vulkan binary — replace this process with it, forwarding the +/// relevant argv verbatim. This keeps the Decky wrapper (which launches the SHELL with +/// these flags) working unchanged until it's repointed at the session binary. +pub fn exec_session() -> glib::ExitCode { + use std::os::unix::process::CommandExt as _; + let forward = [ + "--connect", + "--browse", + "--fp", + "--launch", + "--mgmt", + "--connect-timeout", + ]; + let mut cmd = std::process::Command::new(crate::spawn::session_binary()); + let mut args = std::env::args().skip(1).peekable(); + while let Some(a) = args.next() { + if a == "--fullscreen" || a == "--stats" { + cmd.arg(a); + } else if forward.contains(&a.as_str()) { + cmd.arg(&a); + if let Some(v) = args.peek() { + if !v.starts_with("--") { + cmd.arg(args.next().unwrap()); + } + } + } + } + let err = cmd.exec(); // only returns on failure + eprintln!("exec punktfunk-session: {err}"); + glib::ExitCode::FAILURE +} + /// 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. +/// store the streaming path uses, 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 { @@ -79,50 +126,8 @@ pub fn headless_pair(pin: &str) -> glib::ExitCode { } } -/// `--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). -/// -/// `--launch ` asks the host to launch that library title (store-qualified id from -/// `--library`, e.g. `steam:570` — the Decky wrapper's `PF_LAUNCH`); the raw id doubles -/// as the stream title (best-effort — no extra fetch just for a prettier label). -pub fn cli_connect_request() -> Option { - if arg_value("--browse").is_some() { - return None; // browse mode owns the session lifecycle (precedence over --connect) - } - let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?; - let (addr, port) = parse_host_port(&target); - // An unparsable port (`host:notaport`) used to make the whole request `None` → the app - // silently landed on the hosts page with no session and no message. Fall back to the - // native default like the add-host dialog, and say so, instead of doing nothing. - let port = port.unwrap_or_else(|| { - eprintln!("--connect: unparsable port in '{target}', using default 9777"); - 9777 - }); - // Pull the wake MAC(s) from the store (learned from the host's mDNS `mac` TXT while it was - // online) so a `--connect` to a known host can still be woken if we add that later. - let mac = crate::trust::KnownHosts::load() - .hosts - .iter() - .find(|h| h.addr == addr && h.port == port) - .map(|h| h.mac.clone()) - .unwrap_or_default(); - Some(ConnectRequest { - name: addr.clone(), - addr, - port, - fp_hex: None, - pair_optional: false, - launch: arg_value("--launch").map(|id| (id.clone(), id)), - mac, - }) -} - -/// `--wake host[:port]` — send a Wake-on-LAN magic packet to a saved host and exit, without -/// opening a window. The Decky wrapper calls this before launching the stream so a sleeping host -/// is up by the time `--connect` runs. The MAC comes from the known-hosts store (learned from the +/// `--wake host[:port]` — send a Wake-on-LAN magic packet to a saved host and exit, +/// without opening a window. The MAC comes from the known-hosts store (learned from the /// host's mDNS `mac` TXT while it was online); exits non-zero if none is known yet. pub fn cli_wake() -> glib::ExitCode { let Some(target) = arg_value("--wake") else { @@ -149,44 +154,9 @@ pub fn cli_wake() -> glib::ExitCode { glib::ExitCode::SUCCESS } -/// `--browse host[:port]` — open the gamepad library launcher for that host instead of -/// connecting (the Decky wrapper's `PF_BROWSE`; native port, default 9777). The host must -/// already be paired: the stored pin is what lets the launcher fetch the library and -/// connect silently — no dialog can run under gamescope, so an unpaired target renders -/// the launcher's pair-first scene. Returns the request (name + stored fingerprint from -/// the known-hosts store), whether it's paired, and the mgmt port (`--mgmt `, the -/// wrapper's `PF_MGMT`; default 47990 — browse mode runs no mDNS to learn it). -pub fn cli_browse_request() -> Option<(ConnectRequest, bool, u16)> { - let target = arg_value("--browse")?; - let (addr, port) = parse_host_port(&target); - let port = port.unwrap_or(9777); - let known = crate::trust::KnownHosts::load(); - let k = known - .hosts - .iter() - .find(|h| h.addr == addr && h.port == port); - let mgmt = arg_value("--mgmt") - .and_then(|p| p.parse().ok()) - .unwrap_or(crate::library::DEFAULT_MGMT_PORT); - Some(( - ConnectRequest { - name: k.map_or_else(|| addr.clone(), |k| k.name.clone()), - addr, - port, - fp_hex: k.map(|k| k.fp_hex.clone()), - pair_optional: false, - launch: None, - mac: k.map(|k| k.mac.clone()).unwrap_or_default(), - }, - k.is_some_and(|k| k.paired), - mgmt, - )) -} - /// `--library host[:mgmt_port]` — fetch and print the host's game library over the real -/// mTLS + pinned-fingerprint client, no GTK window (scripting, and the live-API proof -/// that the library HTTP path works against a real host). The pin comes from `--fp HEX` -/// when given, else the known-hosts store (matched by address), else none (TOFU-accept). +/// mTLS + pinned-fingerprint client, no GTK window. 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()), @@ -231,15 +201,14 @@ pub fn shot_scene() -> Option { .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). +/// Render one mock-populated, host-free scene over the already-presented window, then +/// print `PF_SHOT_READY` once it has settled. When `PUNKTFUNK_SHOT_OUT=/path.png` is set +/// the app CAPTURES ITSELF (widget snapshot → gsk render → PNG) — no Xvfb/ImageMagick +/// needed. The stream and gamepad-library scenes are gone with the pages (both live in +/// the session binary now). +pub fn run_shot(ctx: &ShotCtx, scene: &str) { + let sender = &ctx.sender; + // A plausible host for the trust/pair dialogs (fp_hex = 64 hex chars). let mock_req = || ConnectRequest { name: "Living Room PC".to_string(), addr: "192.168.1.42".to_string(), @@ -266,57 +235,45 @@ pub fn run_shot(app: Rc, scene: &str) { // 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(); + let mut target: gtk::Widget = ctx.window.clone().upcast(); + let hosts = &ctx.hosts; 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). + // Saved hosts come from the seeded known-hosts store; on top, inject synthetic + // adverts through the same path the mDNS stream feeds. "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", - )); - } + let _ = hosts.send(HostsMsg::Advert(mock_advert( + "mock-online", + "Living Room PC", + "192.168.1.42", + "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00", + ))); + let _ = hosts.send(HostsMsg::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, || {}); + crate::ui_settings::show(&ctx.window, ctx.settings.clone(), &ctx.gamepad, || {}); + } + "trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()), + "pair" | "05-pair" => { + crate::ui_trust::pin_dialog(&ctx.window, sender, ctx.identity.clone(), mock_req()) } - "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(); - } + let _ = hosts.send(HostsMsg::ShowAddHost); } "shortcuts" | "07-shortcuts" => { - let w = crate::app::shortcuts_window(&app.window); + let w = crate::app::shortcuts_window(&ctx.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). + // no-art placeholders, and one solid-color texture standing in for a poster. "library" | "08-library" => { let (games, art) = mock_library(); - crate::ui_library::open_mock(app.clone(), mock_req(), games, art); - } - // The gamepad launcher (`--browse`) with the same injected entries — cursor sits - // at 1 so both recede directions show; aurora + easing render frozen (shot mode). - "gamepad-library" | "09-gamepad-library" => { - let (games, art) = mock_library(); - let ui = crate::ui_gamepad_library::open_mock(app.clone(), mock_req(), games, art); - app.nav.push(&ui.page); - *app.browse.borrow_mut() = Some(ui); + crate::ui_library::open_mock(&ctx.nav, ctx.identity.clone(), sender, mock_req(), games, art); } other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"), } @@ -328,6 +285,10 @@ pub fn run_shot(app: Rc, scene: &str) { let scene = scene.to_string(); glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || { use std::io::Write as _; + // Self-capture of the dialog scenes (trust/pair/settings/addhost) needs a GL + // renderer: `WidgetPaintable(window)` under the cairo software renderer doesn't + // composite the `AdwDialog` overlay layer (the dialog IS presented — the + // page-content scenes capture fine either way; CI uses GL or the X11 root-grab). let self_capture = std::env::var("PUNKTFUNK_SHOT_OUT") .ok() .filter(|p| !p.is_empty()); @@ -338,17 +299,16 @@ pub fn run_shot(app: Rc, scene: &str) { } 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.) + // Self-capture mode: the shot is on disk — exit so back-to-back scene runs + // don't stack windows on a live desktop. if self_capture.is_some() { std::process::exit(0); } }); } -/// The mock game set shared by the `library` and `gamepad-library` scenes: mixed stores -/// exercising the badge set, plus one solid-colour poster texture. +/// The mock game set for the `library` scene: mixed stores exercising the badge set, +/// plus one solid-colour poster texture. fn mock_library() -> ( Vec, Vec<(String, gtk::gdk::Texture)>, @@ -391,7 +351,6 @@ fn solid_texture(w: i32, h: i32, r: u8, g: u8, b: u8) -> gtk::gdk::Texture { /// `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)); diff --git a/clients/linux/src/launch.rs b/clients/linux/src/launch.rs deleted file mode 100644 index 7f3429b0..00000000 --- a/clients/linux/src/launch.rs +++ /dev/null @@ -1,415 +0,0 @@ -//! 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; - } - // Phase 3 (linux-client-rearchitecture.md): desktop windowed connects run in the - // punktfunk-session Vulkan binary; this in-process GTK presenter remains the legacy - // path for the cases spawn.rs documents. A failed spawn falls through here too. - let child_fp = pin.map(|p| trust::hex(&p)).or_else(|| req.fp_hex.clone()); - let legacy = std::env::var_os("PUNKTFUNK_LEGACY_PRESENTER").is_some_and(|v| v != "0") - || app.fullscreen - || app.browse_ui().is_some() - || opts.waiting.is_some() - || opts.persist_paired; - if !legacy { - if let Some(fp_hex) = child_fp { - if crate::spawn::spawn_session(app.clone(), req.clone(), fp_hex, pin.is_none()) - .is_ok() - { - return; // the child owns the session; busy releases on its exit - } - tracing::warn!("falling back to the in-process presenter"); - } - } - let mode = resolve_mode(&app); - let s = app.settings.borrow(); - // The presenter raises this when hardware frames can't be displayed; the session pump - // demotes the decoder to software (see `SessionParams::force_software`). - let force_software = Arc::new(AtomicBool::new(false)); - 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, - force_software: force_software.clone(), - }; - 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), - force_software, - 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>, - /// Shared with the session pump — the stream page's presenter raises it to demote - /// the decoder to software when hardware frames can't be displayed. - force_software: Arc, - /// 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"), - force_software: self.force_software.clone(), - 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, - chromeless: self.app.fullscreen, - // The attach just went out, so a Deck's built-in pad may not have enumerated - // yet — chromeless (controller-first) shows the chord hint regardless. - pad_connected: self.app.gamepad.active().is_some(), - title, - }); - self.app.nav.push(&p.page); - // Streams start fullscreen by default (Settings toggle) — a streaming window with - // chrome is never what anyone wants mid-game; F11 / the controller chord / the - // top-edge header reveal lead back out. Gaming-Mode launches (`--fullscreen`) - // fullscreen regardless: gamescope fullscreens the window at its level but GTK - // doesn't know it, so the header bar would stay drawn. - if self.app.fullscreen || self.app.settings.borrow().fullscreen_on_stream { - self.app.window.fullscreen(); - } - // A Deck streaming without its raw built-in controller is invisible degradation: - // SDL sees only Steam's virtual X360 pad, so the right trackpad arrives at the - // host as whatever Steam's template synthesizes (a right stick by default) and - // the left trackpad, paddles and gyro not at all. The built-in pad can never - // leave Steam Input ("Steam Controller" is always-required in the shortcut's - // matrix — Disable Steam Input only affects other brands), so raw capture rides - // the session-scoped Valve HIDAPI drivers + the cleared SDL device filter (see - // `app::run`). The real 28DE:1205 identity enumerates shortly after attach — - // check once that settles and say so, instead of streaming silently degraded. - if crate::gamepad::is_steam_deck() { - let app = self.app.clone(); - let stop = self.stop.clone(); - glib::timeout_add_seconds_local_once(4, move || { - if stop.load(std::sync::atomic::Ordering::Relaxed) { - return; // session already over - } - if app.gamepad.active().is_none_or(|pad| pad.steam_virtual) { - tracing::warn!( - "the Deck's raw built-in controller (28DE:1205) never enumerated \ - — only Steam's virtual pad is visible, so trackpads, paddles and \ - gyro can't be captured (sticks + buttons still work). Check the \ - startup log for SDL_GAMECONTROLLER_IGNORE_DEVICES and the \ - Settings controller list." - ); - let toast = adw::Toast::new( - "Steam is only exposing its virtual gamepad — trackpads, paddles \ - and gyro won't reach the game (sticks and buttons still work).", - ); - toast.set_timeout(12); - app.toasts.add_toast(toast); - } - }); - } - 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. Browse - // mode can't: gamescope never maps dialogs, so it renders the advice instead - // (re-pairing is the plugin's job there). - if trust_rejected && !self.tofu && self.app.browse_ui().is_none() { - self.app - .toast("Host fingerprint changed — re-pair with a PIN to continue"); - crate::ui_trust::pin_dialog(self.app.clone(), self.req.clone()); - } else if trust_rejected && !self.tofu { - self.app - .connect_error("Host identity changed — re-pair from the Punktfunk plugin."); - } else { - // Errors land on the hosts page banner / launcher strip, not a transient toast. - self.app.connect_error(&format!("Couldn't connect — {msg}")); - } - } - - /// `Ended`: detach gamepads, pop back to the launcher (browse mode) or the hosts - /// page, and surface the reason. - fn on_ended(&mut self, err: Option) { - self.close_waiting(); - self.app.gamepad.detach(); - // Gaming-Mode `--connect` launch: the app IS the stream. Quit so Steam ends the - // "game" and the Deck returns to Gaming Mode — popping to our own hosts page would - // strand the user in a fullscreen shell with no way back. - if self.app.quit_on_session_end { - if let Some(e) = err { - tracing::warn!(error = %e, "session ended"); - } - self.app.window.close(); - return; - } - // Browse mode: back to the launcher to pick the next game — B there quits to - // Gaming Mode. (The gamepad worker re-opened the pad and armed the held-state - // snapshot on the detach above, so the chord that ended the session fires nothing.) - if let Some(l) = self.app.browse_ui() { - self.app.nav.pop_to_tag("launcher"); - l.on_session_ended(); - if let Some(e) = err { - self.app.connect_error(&e); - } - self.app.busy.set(false); - return; - } - self.app.nav.pop_to_tag("hosts"); - if let Some(h) = self.app.hosts_ui() { - h.set_connecting(None); - } - if let Some(e) = err { - self.app.connect_error(&e); - } - self.app.busy.set(false); - } -} diff --git a/clients/linux/src/main.rs b/clients/linux/src/main.rs index 9f4eb110..90e6244d 100644 --- a/clients/linux/src/main.rs +++ b/clients/linux/src/main.rs @@ -1,46 +1,36 @@ -//! `punktfunk-client` — the native Linux punktfunk/1 client (design: Option A, 2026-06-12). +//! `punktfunk-client` — the native Linux punktfunk/1 desktop shell (relm4/libadwaita). //! -//! GTK4/libadwaita shell · `NativeClient` linked as a crate (no C ABI) · FFmpeg decode → -//! `GtkGraphicsOffload` present · PipeWire audio · SDL3 gamepads. The trust surface -//! mirrors the Apple client: persistent identity, TOFU prompt with the host fingerprint, -//! SPAKE2 PIN pairing. +//! Hosts, pairing/trust, settings, and the desktop library page; every stream (and the +//! console game library) runs in the spawned `punktfunk-session` Vulkan binary — the +//! shell never touches video (punktfunk-planning `linux-client-rearchitecture.md`). -// The UI-agnostic plumbing lives in `pf-client-core`, shared with the upcoming Vulkan -// session binary (design: punktfunk-planning linux-client-rearchitecture.md, Phase 0). -// Root re-exports keep every existing `crate::video`-style path resolving unchanged. +// The UI-agnostic plumbing lives in `pf-client-core`, shared with the session binary. +// Root re-exports keep every `crate::trust`-style path resolving unchanged. #[cfg(target_os = "linux")] -pub use pf_client_core::{audio, discovery, gamepad, keymap, library, session, trust, video, wol}; +pub use pf_client_core::{discovery, gamepad, library, trust, video, wol}; #[cfg(target_os = "linux")] mod app; #[cfg(target_os = "linux")] mod cli; #[cfg(target_os = "linux")] -mod launch; -#[cfg(target_os = "linux")] mod spawn; #[cfg(target_os = "linux")] -mod ui_gamepad_library; -#[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_gl; #[cfg(target_os = "linux")] fn main() -> gtk::glib::ExitCode { app::run() } -/// GTK4/PipeWire/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on -/// macOS (the Mac client lives in clients/apple). +/// GTK4/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on macOS +/// (the Mac client lives in clients/apple). #[cfg(not(target_os = "linux"))] fn main() { eprintln!("punktfunk-client is Linux-only — the macOS client lives in clients/apple"); diff --git a/clients/linux/src/spawn.rs b/clients/linux/src/spawn.rs index b37a364b..517dbc06 100644 --- a/clients/linux/src/spawn.rs +++ b/clients/linux/src/spawn.rs @@ -1,33 +1,48 @@ -//! The shell↔session handoff (punktfunk-planning `linux-client-rearchitecture.md`, -//! Phase 3): desktop connects spawn the `punktfunk-session` Vulkan binary instead of -//! streaming in-process, and this module bridges its stdout contract back into the -//! shell's UI — spinner until `{"ready":true}`, banner from the `{"error"|"ended": …}` -//! JSON line, exit code 3 routed to the re-pair PIN ceremony. -//! -//! The in-process GTK presenter stays for: `PUNKTFUNK_LEGACY_PRESENTER=1`, Gaming-Mode / -//! `--fullscreen` launches and `--browse` (a second toplevel under gamescope is a focus -//! fight — the console path moves wholesale into the session binary in Phase 4), the -//! request-access flow (its "waiting for approval" dialog drives a parked in-process -//! connect), and connects with no fingerprint in hand (the session binary never TOFUs). +//! The shell↔session handoff: every stream runs in the spawned `punktfunk-session` +//! Vulkan binary (the legacy in-process presenter is gone — phase 5 of +//! punktfunk-planning `linux-client-rearchitecture.md`). This module owns the child's +//! lifecycle plumbing — its stdout contract parsed into typed [`AppMsg`]s the relm4 app +//! consumes: spinner until `{"ready":true}`, banner from the `{"error"|"ended": …}` +//! line, exit code 3 + `trust_rejected` routed to the re-pair PIN ceremony. -use crate::app::App; -use crate::trust; +use crate::app::AppMsg; use crate::ui_hosts::ConnectRequest; -use gtk::glib; use std::io::BufRead as _; -use std::process::{Command, Stdio}; -use std::rc::Rc; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; -/// One parsed stdout line / lifecycle event from the session child. +/// Spawn tunables beyond a plain connect. +#[derive(Debug, Default)] +pub struct SpawnOpts { + /// Handshake budget override (`--connect-timeout`) — the request-access flow passes + /// ~185 s because the host PARKS the connection until the operator approves. + pub connect_timeout_secs: Option, + /// Persist the host as *paired* once the child reports ready (request-access: the + /// operator's approval IS the pairing). Plain TOFU persists unpaired. + pub persist_paired: bool, + /// A cancel handle to arm (request-access's waiting dialog): killing the child is + /// the only abort a parked connect has. + pub cancel: Option, +} + +/// Kills the spawned session child (the request-access Cancel button). Safe to call +/// any time; a child that already exited is a no-op. +#[derive(Clone, Debug, Default)] +pub struct CancelHandle(Arc>>); + +impl CancelHandle { + pub fn kill(&self) { + if let Some(child) = self.0.lock().unwrap().as_mut() { + let _ = child.kill(); + } + } +} + +/// One parsed stdout line from the session child's contract. enum ChildEvent { - /// `{"ready":true}` — first frame presented; the connect spinner can stop. Ready, - /// `{"error": msg, "trust_rejected": bool}` — connect failed (the exit follows). Error { msg: String, trust_rejected: bool }, - /// `{"ended": msg}` — the session ran and ended abnormally (host ended, transport…). Ended(String), - /// The child exited (code; -1 = killed by signal). Always the final event. - Exited(i32), } /// Parse one stdout line of the session contract; `None` for anything else (stats…). @@ -50,7 +65,7 @@ fn parse_line(line: &str) -> Option { /// The session binary: installed next to the shell, else `$PATH` (dev runs from /// `target/…` land on the sibling). -fn session_binary() -> std::path::PathBuf { +pub fn session_binary() -> std::path::PathBuf { if let Ok(exe) = std::env::current_exe() { let sibling = exe.with_file_name("punktfunk-session"); if sibling.exists() { @@ -60,21 +75,21 @@ fn session_binary() -> std::path::PathBuf { "punktfunk-session".into() } -/// Spawn the session binary for a connect with `fp_hex` pinned and drive its lifecycle -/// into the UI. `tofu` = the fingerprint came from the host's advert rather than the -/// store — persist it once the child reports ready (the child connects pinned to it, so -/// ready proves the host really holds that identity; stricter than the in-process TOFU, -/// which pins whatever it observes). +/// Spawn the session binary for a connect with `fp_hex` pinned and translate its +/// lifecycle into [`AppMsg`]s. `tofu` = the fingerprint came from the host's advert +/// rather than the store — the app persists it once the child reports ready (the child +/// connects pinned to it, so ready proves the host really holds that identity). /// -/// The caller has already taken `busy`; it is released when the child exits. `Err` = -/// the spawn itself failed (binary missing?) — the caller falls back to the in-process -/// presenter and `busy` stays with it. +/// The caller has already taken `busy`; [`AppMsg::SessionExited`] releases it. `Err` = +/// the spawn itself failed (binary missing?) — surfaced as a connect error. pub fn spawn_session( - app: Rc, + sender: relm4::Sender, req: ConnectRequest, fp_hex: String, tofu: bool, -) -> Result<(), ()> { + fullscreen_on_stream: bool, + opts: SpawnOpts, +) -> Result<(), String> { let mut cmd = Command::new(session_binary()); cmd.arg("--connect") .arg(format!("{}:{}", req.addr, req.port)) @@ -86,94 +101,69 @@ pub fn spawn_session( if let Some((id, _)) = &req.launch { cmd.arg("--launch").arg(id); } - if app.settings.borrow().fullscreen_on_stream { + if let Some(secs) = opts.connect_timeout_secs { + cmd.arg("--connect-timeout").arg(secs.to_string()); + } + if fullscreen_on_stream { cmd.arg("--fullscreen"); } - let mut child = match cmd.spawn() { - Ok(c) => c, - Err(e) => { - tracing::warn!(error = %e, binary = %session_binary().display(), - "punktfunk-session spawn failed"); - return Err(()); - } - }; + let mut child = cmd + .spawn() + .map_err(|e| format!("couldn't start punktfunk-session: {e}"))?; tracing::info!(host = %req.addr, port = req.port, "session binary spawned"); - if let Some(h) = app.hosts_ui() { - h.clear_error(); - h.set_connecting(Some(req.card_key())); - } - - // Reader thread: stdout lines → events, then reap the child and send the final exit. - let (tx, rx) = async_channel::unbounded::(); let stdout = child.stdout.take().expect("piped stdout"); + // Park the child where the cancel handle (and the reader, for the final reap) can + // reach it. + let slot = opts + .cancel + .clone() + .unwrap_or_default(); + *slot.0.lock().unwrap() = Some(child); + + let persist_paired = opts.persist_paired; std::thread::Builder::new() .name("punktfunk-session-io".into()) .spawn(move || { + let mut error: Option<(String, bool)> = None; + let mut ended: Option = None; for line in std::io::BufReader::new(stdout).lines() { let Ok(line) = line else { break }; - if let Some(ev) = parse_line(&line) { - let _ = tx.send_blocking(ev); + match parse_line(&line) { + Some(ChildEvent::Ready) => { + let _ = sender.send(AppMsg::SessionReady { + req: req.clone(), + fp_hex: fp_hex.clone(), + tofu, + persist_paired, + }); + } + Some(ChildEvent::Error { msg, trust_rejected }) => { + error = Some((msg, trust_rejected)); + } + Some(ChildEvent::Ended(msg)) => ended = Some(msg), + None => {} } } - // EOF — the child is exiting (or crashed); reap it so nothing zombies. - let code = child - .wait() - .map(|s| s.code().unwrap_or(-1)) + // EOF — reap the child (killed-by-cancel lands here too; -1 = signal). + let code = slot + .0 + .lock() + .unwrap() + .take() + .and_then(|mut c| c.wait().ok()) + .and_then(|s| s.code()) .unwrap_or(-1); - let _ = tx.send_blocking(ChildEvent::Exited(code)); + tracing::info!(code, "session binary exited"); + let _ = sender.send(AppMsg::SessionExited { + req, + code, + error, + ended, + tofu, + }); }) - .map_err(|e| { - tracing::warn!(error = %e, "session reader thread failed to start"); - })?; - - // Main-loop side: spinner/banner/re-pair per event; busy releases on exit. - glib::spawn_future_local(async move { - let mut error: Option<(String, bool)> = None; - let mut ended: Option = None; - while let Ok(ev) = rx.recv().await { - match ev { - ChildEvent::Ready => { - if let Some(h) = app.hosts_ui() { - h.set_connecting(None); - } - if tofu { - // The advertised fingerprint just proved itself on a real - // connect — pin it (unpaired), like the in-process TOFU. - trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, false); - app.toast(&format!( - "Trusted on first use — fingerprint {}…", - &fp_hex[..16] - )); - } - } - ChildEvent::Error { msg, trust_rejected } => error = Some((msg, trust_rejected)), - ChildEvent::Ended(msg) => ended = Some(msg), - ChildEvent::Exited(code) => { - tracing::info!(code, "session binary exited"); - app.busy.set(false); - if let Some(h) = app.hosts_ui() { - h.set_connecting(None); - } - match (code, error.take(), ended.take()) { - (0, _, None) => {} // clean end — back on the hosts page, no noise - (0, _, Some(reason)) => app.connect_error(&reason), - (_, Some((_, true)), _) if !tofu => { - // The stored pin no longer matches (rotated cert or - // impostor) — route to re-pairing, like the in-process path. - app.toast("Host fingerprint changed — re-pair with a PIN to continue"); - crate::ui_trust::pin_dialog(app.clone(), req.clone()); - } - (_, Some((msg, _)), _) => app.connect_error(&format!("Couldn't connect — {msg}")), - (code, None, _) => app.connect_error(&format!( - "Stream session failed (punktfunk-session exit {code})" - )), - } - break; - } - } - } - }); + .map_err(|e| format!("session reader thread: {e}"))?; Ok(()) } diff --git a/clients/linux/src/ui_gamepad_library.rs b/clients/linux/src/ui_gamepad_library.rs deleted file mode 100644 index 4bc65b8a..00000000 --- a/clients/linux/src/ui_gamepad_library.rs +++ /dev/null @@ -1,1214 +0,0 @@ -//! The gamepad library launcher (`--browse` — the Apple client's console UI ported): -//! a chrome-less, controller-driven coverflow of the host's game library over a drifting -//! "aurora" backdrop. A launches the focused title (the id rides the Hello), B quits back -//! to Gaming Mode, L1/R1 jump. Scope is deliberately library-only — host selection and -//! settings stay in the touch UI; the Decky plugin owns pairing (`--pair`). -//! -//! Input is the gamepad service's menu mode (`gamepad::MenuEvent` over an async channel, -//! same pattern as the stream page's escape events) plus a keyboard fallback -//! (arrows/Enter/Esc), so the launcher is fully drivable with no pad — that's also how CI -//! and the GPU-less dev VM exercise it. Zero popovers/dialogs anywhere: gamescope never -//! maps them (see `ui_settings::gamescope_session`) — every state renders in-page. - -use crate::app::App; -use crate::gamepad::{MenuDir, MenuEvent, MenuPulse}; -use crate::library::{self, GameEntry}; -use crate::trust; -use crate::ui_hosts::ConnectRequest; -use adw::prelude::*; -use gtk::{cairo, gdk, glib, graphene, gsk}; -use std::cell::{Cell, RefCell}; -use std::collections::{HashMap, VecDeque}; -use std::rc::Rc; - -/// Poster geometry: 2:3 covers, sized so the focused poster + detail panel + hint bar fit -/// a Deck's 1280×800 with air. -const POSTER_W: i32 = 220; -const POSTER_H: i32 = 330; -/// Center of the focused card to the center of its first neighbor — the "breathing room" -/// gap around the focus. -const FOCUS_GAP: f64 = 230.0; -/// Center-to-center distance between successive SIDE cards — much tighter than their -/// projected width, so the side stacks overlap like the classic coverflow shelf. -/// Overlap needs paint order: [`restack`] keeps cards closer to the cursor on top -/// (`gtk::Fixed` paints in child order). -const SIDE_SPACING: f64 = 104.0; -/// Cards farther than this from the eased position aren't laid out at all. -const VISIBLE_RANGE: f64 = 5.5; -/// Neighbors recede to this scale… (Apple coverflow parity). -const RECEDE_SCALE: f64 = 0.24; -/// …and swing this many degrees about their own vertical axis under perspective — the -/// actual coverflow tilt (Apple's ±38°), side cards facing the corridor (their inner edge -/// recedes behind the focus). Driven continuously by distance-from-center, so a card -/// flips through flat exactly as it crosses the focus point mid-animation. -const ROTATE_DEG: f64 = 38.0; -/// Perspective depth for the tilt, px — smaller is more dramatic (CSS `perspective()` -/// semantics; per-card vanishing point like Apple's rotation3DEffect). -const PERSPECTIVE: f32 = 800.0; -/// Side cards stay fully opaque — they OVERLAP, and any whole-card translucency bleeds -/// the stack through the card on top; the darkening veil (opaque black, inside the card) -/// carries the entire recede cue. -const RECEDE_DIM: f64 = 0.30; -/// Boundary recoil: a refused move deflects the strip this many px against the push. -const BUMP_PX: f64 = 16.0; -/// L1/R1 jump distance (Apple parity). -const JUMP: i32 = 5; - -// The motion is spring-driven (semi-implicit Euler), not eased — velocity carries across -// retargets, so holding a direction glides and a release settles like a detent instead of -// a lerp. Damping = 2·ζ·√k; dt is clamped far inside the integrator's stability bound. -/// Cursor chase: ζ ≈ 0.85 — settles in ~0.3 s with a whisker of overshoot. -const SPRING_K: f64 = 200.0; -const SPRING_C: f64 = 24.0; -/// Boundary recoil: stiffer and more underdamped (ζ ≈ 0.55) — one visible wobble. -const BUMP_K: f64 = 600.0; -const BUMP_C: f64 = 27.0; - -/// Everything the launcher re-renders from. Kept alive by `App::browse` for the app's -/// lifetime (browse mode never pops this page — streams push on top and return). -struct State { - app: Rc, - /// The browse-target host; cards clone it and add `launch`. `fp_hex` is the stored - /// pin (browse requires a paired host — enforced before any fetch). - req: ConnectRequest, - paired: bool, - mgmt_port: u16, - root: gtk::Overlay, - stack: gtk::Stack, - // Carousel: the integer cursor is the navigation authority (Apple pattern); the - // eased float position chases it on a frame-clock tick. - fixed: gtk::Fixed, - /// The viewport the strip is centered on. `gtk::Fixed` measures its TRANSFORMED - /// children, so far-out cards would otherwise inflate the whole page's minimum width - /// past the window (observed: the top bar allocated wider than the screen, chip - /// off-glass). A ScrolledWindow with External policy exists to swallow exactly that; - /// it's `can_target(false)` so touch/wheel can never actually scroll the strip. - scroller: gtk::ScrolledWindow, - cards: RefCell>, - cursor: Cell, - anim_pos: Cell, - anim_vel: Cell, - bump: Cell, - bump_vel: Cell, - anim_active: Cell, - last_tick: Cell, - animations: bool, - /// Deck (or any low-power box): shrink the per-frame GPU work so navigation stays smooth - /// — fewer laid-out cards (fewer 3D offscreen passes) and a frozen aurora (no 30 Hz - /// full-screen CPU upscale + multi-MB texture upload contending for the iGPU's shared - /// bandwidth). The Deck iGPU otherwise drops to ~16 fps mid-navigation. - low_power: bool, - detail_title: gtk::Label, - detail_store: gtk::Label, - /// Transient error strip on the carousel scene (connect failures land here — the - /// library is still loaded, so no scene swap). - status: gtk::Label, - error_title: gtk::Label, - error_body: gtk::Label, - hints: gtk::Box, - chip: gtk::Label, - games: RefCell>, - /// Poster cache (entry id → texture) — survives re-renders without refetching. - art: RefCell>, - /// The Picture each entry currently renders into, so async art lands on the right card. - pics: RefCell>, - /// The error scene's A action retries the fetch (fetch errors only — not unpaired). - can_retry: Cell, - /// A connect is in flight — hint bar shows the spinner, menu input is parked. - connecting: Cell, - /// Screenshot mode: render injected entries only, never touch network or gamepads. - mock: Cell, -} - -struct Card { - root: gtk::Overlay, - dim: gtk::Box, -} - -/// The launcher page handle, held in `App::browse`. -pub struct LauncherUi { - pub page: adw::NavigationPage, - state: Rc, -} - -impl LauncherUi { - /// A session that started from here ended: restore the hint bar, re-grab keyboard - /// focus (menu mode never turned off — the gamepad worker re-snapshotted on detach). - pub fn on_session_ended(&self) { - self.state.connecting.set(false); - show_scene( - &self.state, - current_scene(&self.state).as_deref().unwrap_or(""), - ); - update_chip(&self.state); - self.state.root.grab_focus(); - } - - /// Surface a connect/session error. With the library on screen the carousel stays put - /// and the message lands on the transient status strip; otherwise the error scene. - pub fn show_error(&self, msg: &str) { - let state = &self.state; - state.connecting.set(false); - if current_scene(state).as_deref() == Some("carousel") { - show_transient_error(state, msg); - show_scene(state, "carousel"); - } else { - state.error_title.set_text("Couldn't connect"); - state.error_body.set_text(msg); - state.can_retry.set(false); - show_scene(state, "error"); - } - } -} - -/// Open the launcher for a saved host and start the fetch. `paired` gates everything: an -/// unpaired target renders the pair-first scene instead (pairing is the plugin's job — no -/// ceremony can run under gamescope). -pub fn open(app: Rc, req: ConnectRequest, paired: bool, mgmt_port: u16) -> Rc { - let ui = build(app.clone(), req, paired, mgmt_port); - app.gamepad.set_menu_mode(true); - attach_menu_input(&ui.state); - // The fake-library dev hook exists precisely for host-less/pairing-less UI work. - if paired || std::env::var_os("PUNKTFUNK_FAKE_LIBRARY").is_some() { - load(&ui.state); - } else { - ui.state.error_title.set_text("Not paired with this host"); - ui.state - .error_body - .set_text("Pair from the Punktfunk plugin first."); - ui.state.can_retry.set(false); - show_scene(&ui.state, "error"); - } - ui -} - -/// Screenshot-scene entry: render injected entries (plus pre-seeded textures, keyed by -/// entry id) with no host, no network, and no gamepad service — the CI `gamepad-library` -/// scene. The cursor starts at 1 so both recede directions are visible. -pub fn open_mock( - app: Rc, - req: ConnectRequest, - games: Vec, - art: Vec<(String, gdk::Texture)>, -) -> Rc { - let ui = build(app, req, true, library::DEFAULT_MGMT_PORT); - ui.state.mock.set(true); - ui.state.art.borrow_mut().extend(art); - if games.is_empty() { - show_scene(&ui.state, "empty"); - } else { - ui.state.cursor.set(1.min(games.len() as i32 - 1)); - *ui.state.games.borrow_mut() = games; - render(&ui.state); - show_scene(&ui.state, "carousel"); - } - ui -} - -fn build(app: Rc, req: ConnectRequest, paired: bool, mgmt_port: u16) -> Rc { - // Scene: loading. - 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("pf-gl-hint"); - loading.append(&loading_label); - - // Scene: error (fetch failures, the unpaired gate, off-carousel connect errors). - let error_title = gtk::Label::new(None); - error_title.add_css_class("pf-gl-error-title"); - let error_body = gtk::Label::new(None); - error_body.add_css_class("pf-gl-hint"); - error_body.set_wrap(true); - error_body.set_max_width_chars(60); - error_body.set_justify(gtk::Justification::Center); - let error = gtk::Box::new(gtk::Orientation::Vertical, 10); - error.set_valign(gtk::Align::Center); - error.append(&error_title); - error.append(&error_body); - - // Scene: empty. - let empty_title = gtk::Label::new(Some("No games found")); - empty_title.add_css_class("pf-gl-error-title"); - let empty_body = gtk::Label::new(Some( - "Install Steam titles or add custom entries in the host's web console.", - )); - empty_body.add_css_class("pf-gl-hint"); - let empty = gtk::Box::new(gtk::Orientation::Vertical, 10); - empty.set_valign(gtk::Align::Center); - empty.append(&empty_title); - empty.append(&empty_body); - - // Scene: the carousel + detail panel. - let fixed = gtk::Fixed::new(); - fixed.set_vexpand(true); - fixed.set_hexpand(true); - let scroller = gtk::ScrolledWindow::builder() - .hscrollbar_policy(gtk::PolicyType::External) - .vscrollbar_policy(gtk::PolicyType::External) - .child(&fixed) - .hexpand(true) - .vexpand(true) - .build(); - scroller.set_can_target(false); - let detail_title = gtk::Label::new(Some(" ")); - detail_title.add_css_class("pf-gl-detail-title"); - detail_title.set_ellipsize(gtk::pango::EllipsizeMode::End); - let detail_store = gtk::Label::new(Some(" ")); - detail_store.add_css_class("pf-gl-detail-store"); - let status = gtk::Label::new(None); - status.add_css_class("pf-gl-status"); - status.set_wrap(true); - status.set_visible(false); - let detail = gtk::Box::new(gtk::Orientation::Vertical, 4); - detail.set_halign(gtk::Align::Center); - detail.set_margin_bottom(12); - detail.append(&detail_title); - detail.append(&detail_store); - detail.append(&status); - let carousel_scene = gtk::Box::new(gtk::Orientation::Vertical, 0); - carousel_scene.append(&scroller); - carousel_scene.append(&detail); - - let stack = gtk::Stack::new(); - stack.set_vexpand(true); - stack.add_named(&loading, Some("loading")); - stack.add_named(&error, Some("error")); - stack.add_named(&empty, Some("empty")); - stack.add_named(&carousel_scene, Some("carousel")); - - // Chrome: host label + controller chip on top, button hints at the bottom. - let host_label = gtk::Label::new(Some(&req.name)); - host_label.add_css_class("pf-gl-host"); - host_label.set_hexpand(true); - host_label.set_halign(gtk::Align::Start); - let chip = gtk::Label::new(None); - chip.add_css_class("pf-gl-chip"); - let top = gtk::Box::new(gtk::Orientation::Horizontal, 12); - top.set_margin_top(18); - top.set_margin_start(24); - top.set_margin_end(24); - top.append(&host_label); - top.append(&chip); - - let hints = gtk::Box::new(gtk::Orientation::Horizontal, 18); - hints.set_margin_bottom(16); - hints.set_margin_start(24); - hints.set_halign(gtk::Align::Start); - - let content = gtk::Box::new(gtk::Orientation::Vertical, 0); - content.append(&top); - content.append(&stack); - content.append(&hints); - - let low_power = crate::gamepad::is_steam_deck(); - let root = gtk::Overlay::new(); - root.add_css_class("pf-gl-page"); - // On the Deck the animated aurora's per-frame CPU upscale + texture upload starves the - // coverflow of iGPU bandwidth — freeze it (drift is centimeters/minute, unnoticeable). - root.set_child(Some(&build_aurora(low_power))); - root.add_overlay(&content); - root.set_focusable(true); - - let page = adw::NavigationPage::builder() - .title(req.name.clone()) - .tag("launcher") - .child(&root) - .build(); - - let state = Rc::new(State { - app, - req, - paired, - mgmt_port, - root: root.clone(), - stack, - fixed, - scroller, - cards: RefCell::new(Vec::new()), - cursor: Cell::new(0), - anim_pos: Cell::new(0.0), - anim_vel: Cell::new(0.0), - bump: Cell::new(0.0), - bump_vel: Cell::new(0.0), - anim_active: Cell::new(false), - last_tick: Cell::new(0), - animations: animations_enabled(), - low_power, - detail_title, - detail_store, - status, - error_title, - error_body, - hints, - chip, - games: RefCell::new(Vec::new()), - art: RefCell::new(HashMap::new()), - pics: RefCell::new(HashMap::new()), - can_retry: Cell::new(false), - connecting: Cell::new(false), - mock: Cell::new(false), - }); - - // Keyboard fallback — same event vocabulary through the same handler, so the launcher - // is fully drivable padless (dev VM, CI). - let key = gtk::EventControllerKey::new(); - key.set_propagation_phase(gtk::PropagationPhase::Capture); - { - let weak = Rc::downgrade(&state); - key.connect_key_pressed(move |_, keyval, _, _| { - let Some(state) = weak.upgrade() else { - return glib::Propagation::Proceed; - }; - use gtk::gdk::Key; - let ev = match keyval { - Key::Left => MenuEvent::Move(MenuDir::Left), - Key::Right => MenuEvent::Move(MenuDir::Right), - Key::Up => MenuEvent::Move(MenuDir::Up), - Key::Down => MenuEvent::Move(MenuDir::Down), - Key::Return | Key::KP_Enter | Key::space => MenuEvent::Confirm, - Key::Escape | Key::BackSpace => MenuEvent::Back, - Key::Page_Up => MenuEvent::JumpBack, - Key::Page_Down => MenuEvent::JumpForward, - _ => return glib::Propagation::Proceed, - }; - handle_menu_event(&state, ev); - glib::Propagation::Stop - }); - } - root.add_controller(key); - { - let root = root.clone(); - root.clone().connect_map(move |_| { - root.grab_focus(); - }); - } - - // The aurora area resizes with the page — piggyback the carousel relayout on it - // (gtk::Fixed has no resize signal of its own). - if let Some(aurora) = root.child().and_downcast::() { - let weak = Rc::downgrade(&state); - aurora.connect_resize(move |_, _, _| { - if let Some(state) = weak.upgrade() { - kick_anim(&state); - } - }); - } - - update_chip(&state); - // The chip tracks pad hot-plug lazily — nothing else needs a poll. - { - let weak = Rc::downgrade(&state); - glib::timeout_add_seconds_local(2, move || match weak.upgrade() { - Some(state) => { - update_chip(&state); - glib::ControlFlow::Continue - } - None => glib::ControlFlow::Break, - }); - } - - Rc::new(LauncherUi { page, state }) -} - -/// Menu events from the gamepad worker → the same handler the keyboard feeds. -fn attach_menu_input(state: &Rc) { - let rx = state.app.gamepad.menu_events(); - let weak = Rc::downgrade(state); - glib::spawn_future_local(async move { - while let Ok(ev) = rx.recv().await { - let Some(state) = weak.upgrade() else { break }; - handle_menu_event(&state, ev); - } - }); -} - -fn current_scene(state: &State) -> Option { - state.stack.visible_child_name() -} - -/// Route one menu action by scene. Parked while a connect is in flight or a stream page -/// owns the app (`busy` — the worker also stops translating once attached; this covers -/// the connect window before attach). -fn handle_menu_event(state: &Rc, ev: MenuEvent) { - if state.app.busy.get() || state.connecting.get() { - return; - } - match current_scene(state).as_deref() { - Some("carousel") => match ev { - MenuEvent::Move(MenuDir::Left) => step(state, -1, false), - MenuEvent::Move(MenuDir::Right) => step(state, 1, false), - MenuEvent::JumpBack => step(state, -JUMP, true), - MenuEvent::JumpForward => step(state, JUMP, true), - MenuEvent::Confirm => launch_selected(state), - MenuEvent::Back => quit(state), - // Single row: up/down are neither moves nor boundaries (Apple parity). - MenuEvent::Move(_) | MenuEvent::Secondary | MenuEvent::Tertiary => {} - }, - Some("error") => match ev { - MenuEvent::Confirm if state.can_retry.get() => load(state), - MenuEvent::Back => quit(state), - _ => {} - }, - Some("empty" | "loading") if ev == MenuEvent::Back => quit(state), - _ => {} - } -} - -/// One semi-implicit-Euler step of a damped spring toward `target`: acceleration from -/// displacement and damping, velocity integrated before position — the standard stable -/// discretization. -fn spring_step(pos: f64, vel: f64, target: f64, k: f64, c: f64, dt: f64) -> (f64, f64) { - let vel = vel + (k * (target - pos) - c * vel) * dt; - (pos + vel * dt, vel) -} - -/// Advance a damped spring by a whole frame, integrating in ≤ 8 ms substeps (unit-tested -/// for convergence and long-frame stability). A stalled frame (dt clamped to 50 ms) would -/// put the stiff bump spring at ω·dt ≈ 1.2 — inside the integrator's formal stability -/// bound but distorted enough to ring for ages; substeps keep ω·dt ≈ 0.2, so the motion -/// feels identical at any frame rate. -fn spring_advance(mut pos: f64, mut vel: f64, target: f64, k: f64, c: f64, dt: f64) -> (f64, f64) { - let n = (dt / 0.008).ceil().max(1.0) as usize; - let h = dt / n as f64; - for _ in 0..n { - (pos, vel) = spring_step(pos, vel, target, k, c, h); - } - (pos, vel) -} - -/// Pure cursor arithmetic for a move/jump (unit-tested): `clamp` lands jumps on the ends, -/// a plain step refuses to leave them. -#[derive(Debug, PartialEq, Eq)] -enum StepResult { - Moved(i32), - Boundary, -} - -fn step_cursor(cursor: i32, len: usize, delta: i32, clamp: bool) -> StepResult { - if len == 0 { - return StepResult::Boundary; - } - let max = len as i32 - 1; - let target = if clamp { - (cursor + delta).clamp(0, max) - } else { - cursor + delta - }; - if target == cursor || target < 0 || target > max { - StepResult::Boundary - } else { - StepResult::Moved(target) - } -} - -fn step(state: &Rc, delta: i32, clamp: bool) { - let len = state.games.borrow().len(); - match step_cursor(state.cursor.get(), len, delta, clamp) { - StepResult::Moved(to) => { - state.cursor.set(to); - state.app.gamepad.menu_rumble(MenuPulse::Move); - update_detail(state); - restack(state); - kick_anim(state); - } - StepResult::Boundary => { - // Recoil against the push: advancing shifts cards left, so a refused - // right-push deflects left (and vice versa); the bump spring wobbles it back. - state.bump.set(-BUMP_PX * f64::from(delta.signum())); - state.bump_vel.set(0.0); - state.app.gamepad.menu_rumble(MenuPulse::Boundary); - kick_anim(state); - } - } -} - -/// A on the focused poster: request the session with the library id riding the Hello. -/// Direct `launch::start_session` — trust is already established (browse requires the -/// stored pin) and every other `initiate_connect` branch opens a dialog gamescope can't map. -fn launch_selected(state: &Rc) { - if state.mock.get() { - return; - } - let (id, title) = { - let games = state.games.borrow(); - let Some(g) = games.get(state.cursor.get() as usize) else { - return; - }; - (g.id.clone(), g.title.clone()) - }; - state.app.gamepad.menu_rumble(MenuPulse::Confirm); - state.status.set_visible(false); - state.connecting.set(true); - show_scene(state, "carousel"); - let mut req = state.req.clone(); - req.launch = Some((id, title)); - let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32); - crate::launch::start_session(state.app.clone(), req, pin); -} - -/// B at the launcher root: the app IS the "game" — closing it returns the Deck to -/// Gaming Mode (same mechanism as `quit_on_session_end`). -fn quit(state: &Rc) { - state.app.window.close(); -} - -/// Fetch the library off the main thread and route the result into a scene (the -/// `ui_library::load` pattern). `PUNKTFUNK_FAKE_LIBRARY=` short-circuits with -/// entries from disk — the GPU-less/pairing-less dev path. -fn load(state: &Rc) { - if state.mock.get() { - return; - } - if let Ok(path) = std::env::var("PUNKTFUNK_FAKE_LIBRARY") { - load_fake(state, &path); - return; - } - if !state.paired { - return; - } - show_scene(state, "loading"); - let addr = state.req.addr.clone(); - let port = state.mgmt_port; - 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() => show_scene(&state, "empty"), - Ok(games) => { - state.cursor.set(0); - *state.games.borrow_mut() = games; - render(&state); - show_scene(&state, "carousel"); - load_art(&state); - } - Err(e) => { - state.error_title.set_text("Couldn't load the library"); - state.error_body.set_text(&e.to_string()); - state.can_retry.set(true); - show_scene(&state, "error"); - } - } - }); -} - -/// Dev hook: entries from a JSON file; portrait paths starting with `/` load from disk. -fn load_fake(state: &Rc, path: &str) { - let games: Vec = std::fs::read_to_string(path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default(); - if games.is_empty() { - show_scene(state, "empty"); - return; - } - { - let mut art = state.art.borrow_mut(); - for g in &games { - if let Some(p) = g.art.portrait.as_deref().filter(|p| p.starts_with('/')) { - if let Ok(bytes) = std::fs::read(p) { - if let Ok(tex) = gdk::Texture::from_bytes(&glib::Bytes::from_owned(bytes)) { - art.insert(g.id.clone(), tex); - } - } - } - } - } - state.cursor.set(0); - *state.games.borrow_mut() = games; - render(state); - show_scene(state, "carousel"); -} - -/// (Re)build the poster cards from the current games snapshot and lay them out. -fn render(state: &Rc) { - while let Some(child) = state.fixed.first_child() { - state.fixed.remove(&child); - } - state.pics.borrow_mut().clear(); - let games = state.games.borrow(); - let mut cards = Vec::with_capacity(games.len()); - for game in games.iter() { - let card = build_card(state, game); - state.fixed.put(&card.root, 0.0, 0.0); - cards.push(card); - } - drop(games); - *state.cards.borrow_mut() = cards; - update_detail(state); - // Snap the sprung position onto the cursor — a fresh render has no old position to - // animate from. - state.anim_pos.set(f64::from(state.cursor.get())); - state.anim_vel.set(0.0); - restack(state); - kick_anim(state); -} - -/// One coverflow card: monogram placeholder → async poster → store badge, plus the black -/// `dim` veil whose opacity implements the brightness recede. -fn build_card(state: &Rc, game: &GameEntry) -> Card { - let monogram = gtk::Label::new(Some(&crate::ui_library::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(crate::ui_library::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(8); - badge.set_margin_top(8); - - let dim = gtk::Box::new(gtk::Orientation::Vertical, 0); - dim.add_css_class("pf-gl-dim"); - dim.set_opacity(0.0); - dim.set_can_target(false); - - let root = gtk::Overlay::new(); - root.set_child(Some(&placeholder)); - root.add_overlay(&pic); - root.add_overlay(&badge); - root.add_overlay(&dim); - root.add_css_class("pf-gl-poster"); - root.set_overflow(gtk::Overflow::Hidden); - root.set_size_request(POSTER_W, POSTER_H); - Card { root, dim } -} - -/// Fetch poster art for every uncached entry (shared worker pool) and texture the cards -/// as results land. -fn load_art(state: &Rc) { - let base = library::base_url(&state.req.addr, state.mgmt_port); - let jobs: VecDeque<(String, Vec)> = { - let cache = state.art.borrow(); - state - .games - .borrow() - .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 pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32); - let rx = library::spawn_art_fetch(base, state.app.identity.clone(), pin, jobs); - 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 }; - 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 focused title + store tag under the strip — updated synchronously on every move -/// (the scroll chases; Apple parity). Single spaces keep the layout from jumping when -/// there's nothing to show. -fn update_detail(state: &State) { - let games = state.games.borrow(); - match games.get(state.cursor.get() as usize) { - Some(g) => { - state.detail_title.set_text(&g.title); - state - .detail_store - .set_text(&crate::ui_library::store_label(&g.store).to_uppercase()); - } - None => { - state.detail_title.set_text(" "); - state.detail_store.set_text(" "); - } - } -} - -fn update_chip(state: &State) { - match state.app.gamepad.active() { - Some(p) => state.chip.set_text(&p.name), - None => state.chip.set_text("No controller — keyboard works too"), - } -} - -/// Swap the visible scene and set its hint bar. Hints double as the connect affordance. -fn show_scene(state: &Rc, scene: &str) { - if !scene.is_empty() { - state.stack.set_visible_child_name(scene); - } - let hints = &state.hints; - while let Some(c) = hints.first_child() { - hints.remove(&c); - } - if state.connecting.get() { - let spinner = gtk::Spinner::new(); - spinner.start(); - hints.append(&spinner); - let label = gtk::Label::new(Some("Connecting…")); - label.add_css_class("pf-gl-hint"); - hints.append(&label); - return; - } - let items: &[(&str, &str)] = match current_scene(state).as_deref() { - Some("carousel") => &[("A", "Play"), ("B", "Quit"), ("L1 · R1", "Jump")], - Some("error") if state.can_retry.get() => &[("A", "Retry"), ("B", "Quit")], - _ => &[("B", "Quit")], - }; - for (glyph, text) in items { - let g = gtk::Label::new(Some(glyph)); - g.add_css_class("pf-gl-glyph"); - let t = gtk::Label::new(Some(text)); - t.add_css_class("pf-gl-hint"); - let pair = gtk::Box::new(gtk::Orientation::Horizontal, 8); - pair.append(&g); - pair.append(&t); - hints.append(&pair); - } -} - -/// A connect failure with the carousel on screen: an inline strip, cleared after a few -/// seconds (never a dialog). -fn show_transient_error(state: &Rc, msg: &str) { - state.status.set_text(msg); - state.status.set_visible(true); - let weak = Rc::downgrade(state); - glib::timeout_add_seconds_local_once(6, move || { - if let Some(state) = weak.upgrade() { - state.status.set_visible(false); - } - }); -} - -// ---- Carousel layout + animation ------------------------------------------------------ - -/// Start (or fast-path) the layout animation on a frame-clock tick: two damped springs — -/// the strip position chasing the cursor and the boundary bump returning to rest — and -/// the tick uninstalls itself once both settle, so the launcher idles at zero layout -/// work. Retargeting mid-flight just moves the spring's goal; velocity carries over, so -/// held-repeat scrolling glides instead of restarting a curve every step. Always -/// tick-driven (even with animations off): the first kick usually lands before -/// `gtk::Fixed` has an allocation (built → rendered → THEN pushed/mapped), so the tick -/// waits for a real size instead of laying out into 0×0 and leaving every card stacked -/// at the origin. -fn kick_anim(state: &Rc) { - if state.anim_active.replace(true) { - return; // tick already running — it picks the new target up - } - state.last_tick.set(0); - let weak = Rc::downgrade(state); - state.fixed.add_tick_callback(move |_, clock| { - let Some(state) = weak.upgrade() else { - return glib::ControlFlow::Break; - }; - if state.scroller.width() <= 0 || state.scroller.height() <= 0 { - return glib::ControlFlow::Continue; // not allocated yet — wait, don't settle - } - if !state.animations { - state.anim_pos.set(f64::from(state.cursor.get())); - state.anim_vel.set(0.0); - state.bump.set(0.0); - state.bump_vel.set(0.0); - relayout(&state); - state.anim_active.set(false); - return glib::ControlFlow::Break; - } - let now = clock.frame_time(); - let last = state.last_tick.replace(now); - // Clamped well inside the semi-implicit integrator's stability bound (ω·dt < 2; - // the stiffer spring has ω ≈ 24.5 → dt must stay < 80 ms). - let dt = if last == 0 { - 1.0 / 60.0 - } else { - ((now - last) as f64 / 1e6).clamp(0.0, 0.05) - }; - let target = f64::from(state.cursor.get()); - let (mut pos, mut vel) = spring_advance( - state.anim_pos.get(), - state.anim_vel.get(), - target, - SPRING_K, - SPRING_C, - dt, - ); - if (target - pos).abs() < 0.001 && vel.abs() < 0.01 { - pos = target; - vel = 0.0; - } - state.anim_pos.set(pos); - state.anim_vel.set(vel); - let (mut bump, mut bvel) = spring_advance( - state.bump.get(), - state.bump_vel.get(), - 0.0, - BUMP_K, - BUMP_C, - dt, - ); - if bump.abs() < 0.3 && bvel.abs() < 4.0 { - bump = 0.0; - bvel = 0.0; - } - state.bump.set(bump); - state.bump_vel.set(bvel); - relayout(&state); - if pos == target && bump == 0.0 { - state.anim_active.set(false); - glib::ControlFlow::Break - } else { - glib::ControlFlow::Continue - } - }); -} - -/// Re-stack the strip's paint order so cards CLOSER to the (integer) cursor draw on top — -/// the dense side stacks overlap and `gtk::Fixed` paints in child order. Runs on cursor -/// changes only (not per frame); re-inserting an existing child just repositions it in -/// the widget list, layout properties (position/transform) are untouched. -fn restack(state: &State) { - let cards = state.cards.borrow(); - if cards.is_empty() { - return; - } - let cur = state.cursor.get(); - let mut order: Vec = (0..cards.len()).collect(); - // Farthest first (painted first = bottom); stable, so the equidistant left/right - // neighbors keep a deterministic order (they never overlap each other anyway). - order.sort_by_key(|&i| std::cmp::Reverse((i as i32 - cur).abs())); - let mut prev: Option = None; - for &i in &order { - let w: gtk::Widget = cards[i].root.clone().upcast(); - w.insert_after(&state.fixed, prev.as_ref()); - prev = Some(w); - } -} - -/// Place every card from the eased position: center-focused scale/opacity recede, the -/// whole strip offset by the decaying boundary bump. Off-strip cards are hidden. -/// Centering is on the VIEWPORT (the scroller), not the Fixed — the Fixed's own -/// allocation grows with its transformed children (see `State::scroller`). -fn relayout(state: &State) { - let w = f64::from(state.scroller.width()); - let h = f64::from(state.scroller.height()); - if w <= 0.0 || h <= 0.0 { - return; - } - let pos = state.anim_pos.get(); - let bump = state.bump.get(); - // Each laid-out side card is a non-affine (perspective + rotate_3d) transform, which GSK - // renders through its own offscreen pass — so the visible count is the per-frame GPU cost. - // Trim it hard on the Deck; desktop keeps the full deep shelf. - let range = if state.low_power { 3.0 } else { VISIBLE_RANGE }; - for (i, card) in state.cards.borrow().iter().enumerate() { - let d = i as f64 - pos; - let a = d.abs(); - if a > range { - card.root.set_visible(false); - continue; - } - card.root.set_visible(true); - let prox = a.min(1.0); - let scale = 1.0 - prox * RECEDE_SCALE; - // Coverflow tilt: side cards face the corridor (inner edge receding behind the - // focus), flipping through flat exactly at the focus point. Rotation is about the - // card's own vertical center axis, so the transform moves the origin to the card - // center first and draws centered last. - let angle = -d.clamp(-1.0, 1.0) * ROTATE_DEG; - // Piecewise strip layout: a full FOCUS_GAP around the focused card, then the - // dense side stacks (the classic coverflow shelf). - let offset = if a <= 1.0 { - d * FOCUS_GAP - } else { - d.signum() * (FOCUS_GAP + (a - 1.0) * SIDE_SPACING) - }; - let cx = w / 2.0 + offset + bump; - let cy = h / 2.0; - card.dim.set_opacity(prox * RECEDE_DIM); - let transform = gsk::Transform::new() - .translate(&graphene::Point::new(cx as f32, cy as f32)) - .perspective(PERSPECTIVE) - .rotate_3d(angle as f32, &graphene::Vec3::y_axis()) - .scale(scale as f32, scale as f32) - .translate(&graphene::Point::new( - -(POSTER_W as f32) / 2.0, - -(POSTER_H as f32) / 2.0, - )); - state - .fixed - .set_child_transform(&card.root, Some(&transform)); - } -} - -// ---- Aurora backdrop ------------------------------------------------------------------- - -/// Low-res render target for the blob field — radial gradients in software are cheap at -/// 256×160 (< 1 ms) and the bilinear upscale is exactly what a blurry gradient field wants. -const AURORA_W: i32 = 256; -const AURORA_H: i32 = 160; - -/// One drifting color blob (the Swift `GamepadScreenBackground` table, verbatim): base -/// position + drift ellipse in unit coordinates, angular speeds in rad/s (30–90 s -/// periods), a breathing radius (fraction of the larger dimension), and the layer opacity. -struct Blob { - rgb: (f64, f64, f64), - center: (f64, f64), - drift: (f64, f64), - speed: (f64, f64), - phase: (f64, f64), - radius: f64, - breathe: (f64, f64), - opacity: f64, -} - -/// The brand violet, a deeper indigo, a warmer plum, and a cool blue — related hues so -/// the field shifts within one temperature instead of strobing through the rainbow. -const BLOBS: [Blob; 4] = [ - Blob { - rgb: (0.53, 0.47, 0.96), // brand violet - center: (0.30, 0.24), - drift: (0.16, 0.10), - speed: (0.111, 0.083), - phase: (0.0, 1.9), - radius: 0.52, - breathe: (0.07, 0.061), - opacity: 0.52, - }, - Blob { - rgb: (0.24, 0.20, 0.72), // deep indigo - center: (0.78, 0.66), - drift: (0.13, 0.14), - speed: (0.071, 0.096), - phase: (2.4, 0.7), - radius: 0.58, - breathe: (0.08, 0.049), - opacity: 0.55, - }, - Blob { - rgb: (0.62, 0.30, 0.80), // plum - center: (0.16, 0.82), - drift: (0.12, 0.09), - speed: (0.089, 0.067), - phase: (4.1, 3.2), - radius: 0.44, - breathe: (0.09, 0.078), - opacity: 0.42, - }, - Blob { - rgb: (0.22, 0.38, 0.86), // cool blue - center: (0.70, 0.12), - drift: (0.10, 0.08), - speed: (0.059, 0.104), - phase: (1.2, 5.0), - radius: 0.40, - breathe: (0.06, 0.055), - opacity: 0.38, - }, -]; - -/// Animations are wanted unless the desktop disabled them or a screenshot scene needs a -/// deterministic frame (then the field renders once, frozen at t = 0 — reduce-motion parity). -fn animations_enabled() -> bool { - crate::cli::shot_scene().is_none() - && gtk::Settings::default().is_none_or(|s| s.is_gtk_enable_animations()) -} - -/// The full-bleed aurora: a DrawingArea re-rendered at ~30 Hz off the frame clock (the -/// Swift TimelineView cadence — drift is centimeters per minute, display rate would be -/// wasted heat on a couch device). -fn build_aurora(low_power: bool) -> gtk::DrawingArea { - let area = gtk::DrawingArea::new(); - area.set_hexpand(true); - area.set_vexpand(true); - let t = Rc::new(Cell::new(0.0f64)); - let cache = RefCell::new(None::); - { - let t = t.clone(); - area.set_draw_func(move |_, cr, w, h| draw_aurora(cr, w, h, t.get(), &cache)); - } - // Deck: render once, frozen — the 30 Hz tick's CPU upscale + texture upload is the - // bandwidth cost that starves the coverflow. Desktop keeps the live drift. - if animations_enabled() && !low_power { - let start = Cell::new(0i64); - let last = Cell::new(0i64); - area.add_tick_callback(move |area, clock| { - let now = clock.frame_time(); - if start.get() == 0 { - start.set(now); - } - if now - last.get() >= 33_000 { - last.set(now); - t.set((now - start.get()) as f64 / 1e6); - area.queue_draw(); - } - glib::ControlFlow::Continue - }); - } - area -} - -/// Black → additive blob field → legibility scrim, composed at low res and blitted -/// scaled. The scrim keeps the title (top) and detail/hints (bottom) on near-black -/// whatever the blobs are doing behind them. -fn draw_aurora( - cr: &cairo::Context, - w: i32, - h: i32, - t: f64, - cache: &RefCell>, -) { - let mut cached = cache.borrow_mut(); - if cached.is_none() { - *cached = cairo::ImageSurface::create(cairo::Format::ARgb32, AURORA_W, AURORA_H).ok(); - } - let Some(surf) = cached.as_ref() else { - let _ = cr.save(); - cr.set_source_rgb(0.0, 0.0, 0.0); - let _ = cr.paint(); - let _ = cr.restore(); - return; - }; - { - let Ok(c) = cairo::Context::new(surf) else { - return; - }; - let (fw, fh) = (f64::from(AURORA_W), f64::from(AURORA_H)); - let side = fw.max(fh); - c.set_source_rgb(0.0, 0.0, 0.0); - let _ = c.paint(); - c.set_operator(cairo::Operator::Add); - for b in &BLOBS { - let x = (b.center.0 + b.drift.0 * (t * b.speed.0 + b.phase.0).sin()) * fw; - let y = (b.center.1 + b.drift.1 * (t * b.speed.1 + b.phase.1).cos()) * fh; - let r = side * b.radius * (1.0 + b.breathe.0 * (t * b.breathe.1 + b.phase.0).sin()); - let g = cairo::RadialGradient::new(x, y, 0.0, x, y, r / 2.0); - g.add_color_stop_rgba(0.0, b.rgb.0, b.rgb.1, b.rgb.2, b.opacity); - g.add_color_stop_rgba(1.0, b.rgb.0, b.rgb.1, b.rgb.2, 0.0); - let _ = c.set_source(&g); - let _ = c.paint(); - } - c.set_operator(cairo::Operator::Over); - let scrim = cairo::LinearGradient::new(0.0, 0.0, 0.0, fh); - scrim.add_color_stop_rgba(0.0, 0.0, 0.0, 0.0, 0.55); - scrim.add_color_stop_rgba(0.35, 0.0, 0.0, 0.0, 0.15); - scrim.add_color_stop_rgba(0.65, 0.0, 0.0, 0.0, 0.20); - scrim.add_color_stop_rgba(1.0, 0.0, 0.0, 0.0, 0.60); - let _ = c.set_source(&scrim); - let _ = c.paint(); - } - let _ = cr.save(); - cr.scale( - f64::from(w) / f64::from(AURORA_W), - f64::from(h) / f64::from(AURORA_H), - ); - let _ = cr.set_source_surface(surf, 0.0, 0.0); - let _ = cr.paint(); - let _ = cr.restore(); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn step_refuses_the_ends() { - assert_eq!(step_cursor(0, 6, -1, false), StepResult::Boundary); - assert_eq!(step_cursor(5, 6, 1, false), StepResult::Boundary); - assert_eq!(step_cursor(0, 6, 1, false), StepResult::Moved(1)); - assert_eq!(step_cursor(5, 6, -1, false), StepResult::Moved(4)); - } - - #[test] - fn jump_clamps_onto_the_ends() { - assert_eq!(step_cursor(1, 6, -5, true), StepResult::Moved(0)); - assert_eq!(step_cursor(4, 6, 5, true), StepResult::Moved(5)); - // Already at the end: a clamped jump is a boundary, not a no-op move. - assert_eq!(step_cursor(0, 6, -5, true), StepResult::Boundary); - assert_eq!(step_cursor(5, 6, 5, true), StepResult::Boundary); - } - - #[test] - fn empty_list_is_all_boundary() { - assert_eq!(step_cursor(0, 0, 1, false), StepResult::Boundary); - assert_eq!(step_cursor(0, 0, -5, true), StepResult::Boundary); - } - - /// Drive a spring from rest at 0 toward 1 and report (settle step, peak position). - fn run_spring(k: f64, c: f64, dt: f64, max_steps: usize) -> (Option, f64) { - let (mut pos, mut vel) = (0.0f64, 0.0f64); - let mut peak = 0.0f64; - for i in 0..max_steps { - (pos, vel) = spring_advance(pos, vel, 1.0, k, c, dt); - peak = peak.max(pos); - if (1.0 - pos).abs() < 0.001 && vel.abs() < 0.01 { - return (Some(i), peak); - } - } - (None, peak) - } - - #[test] - fn cursor_spring_settles_fast_without_visible_overshoot() { - let (settled, peak) = run_spring(SPRING_K, SPRING_C, 1.0 / 60.0, 600); - let steps = settled.expect("spring never settled"); - // ~0.3 s at 60 Hz; ζ = 0.85's theoretical overshoot (~0.6 %) is under the settle - // epsilon, so only bound it — the springy character comes from velocity carry. - assert!(steps < 45, "settled in {steps} frames (> 0.75 s)"); - assert!(peak < 1.05, "overshoot too big: {peak}"); - } - - #[test] - fn springs_converge_at_the_clamped_max_frame() { - // dt is clamped to 50 ms in the tick; spring_advance's substeps must keep both - // parameter sets convergent and bounded there (a raw 50 ms step would leave the - // stiff bump spring ringing at ω·dt ≈ 1.2). - for (k, c) in [(SPRING_K, SPRING_C), (BUMP_K, BUMP_C)] { - let (settled, peak) = run_spring(k, c, 0.05, 600); - assert!(settled.is_some(), "k={k}: no convergence at dt=0.05"); - assert!(peak < 1.2, "k={k}: unstable at dt=0.05 (peak {peak})"); - } - } - - #[test] - fn bump_spring_returns_to_rest_from_a_deflection() { - // The boundary recoil starts displaced (±16 px) at zero velocity and must die out. - let (mut pos, mut vel) = (-BUMP_PX, 0.0f64); - for _ in 0..600 { - (pos, vel) = spring_advance(pos, vel, 0.0, BUMP_K, BUMP_C, 1.0 / 60.0); - if pos.abs() < 0.3 && vel.abs() < 4.0 { - return; - } - } - panic!("bump never settled (pos {pos}, vel {vel})"); - } -} diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index ecf98381..0e8ee553 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -1,23 +1,26 @@ -//! 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. +//! The hosts page as a relm4 component: adaptive card grids for saved (trusted/paired) +//! and mDNS-discovered hosts — avatar + name + `addr:port` + status pills, online pips, +//! dashed discovered cards, an overflow menu, an add-host dialog, and a connect-failure +//! banner. Cards are a [`FactoryVecDeque`]; both grids re-populate from one state +//! snapshot (known hosts on disk + the live advert map) on every change, so dedup and +//! the online pips stay consistent. Actions leave as typed [`HostsOutput`]s — the +//! callback bag and `Rc>` pokes of the pre-relm4 shell are gone. use crate::discovery::{self, DiscoveredHost, DiscoveryEvent}; use crate::trust::{KnownHost, KnownHosts, Settings}; use adw::prelude::*; use gtk::{gio, glib}; +use relm4::factory::FactoryVecDeque; +use relm4::prelude::*; use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; /// What the user asked to connect to. `fp_hex` comes from the mDNS TXT record when the -/// host was discovered (drives the trust decision *before* connecting); manual entries have -/// none. `pair_optional` is true ONLY when a discovered host advertised `pair=optional`, -/// which is the sole case in which the reduced-security TOFU path may be offered — every -/// other case (pair=required, unknown/empty policy, manual entry) mandates PIN pairing. +/// host was discovered (drives the trust decision *before* connecting); manual entries +/// have none. `pair_optional` is true ONLY when a discovered host advertised +/// `pair=optional` — the sole case in which the reduced-security TOFU path may be +/// offered; every other case mandates PIN pairing. #[derive(Clone, Debug)] pub struct ConnectRequest { pub name: String, @@ -25,17 +28,14 @@ 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. + /// A library title to launch on connect (`(library id, display name)`). pub launch: Option<(String, String)>, - /// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert). Used to send a - /// magic packet before connecting to an offline host. Empty when none is known. + /// Wake-on-LAN MAC(s) for this host. Empty when none is known. pub mac: Vec, } impl ConnectRequest { - /// The key the hosts page tracks an in-flight connect under (the card that swaps its + /// The key the 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 @@ -44,69 +44,644 @@ impl ConnectRequest { } } -/// 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, - /// Connect to an OFFLINE saved host with a known MAC: wake it, poll until it's up (re-keying a - /// new DHCP IP), then connect. Falls back to `on_connect` when there's nothing to wake. - pub on_wake_connect: Rc, - pub on_speed_test: Rc, - pub on_pair: Rc, - pub on_library: Rc, +// --- The card factory --------------------------------------------------------------------- + +/// One card's full render input — rebuilt (clear + repopulate) on every state change, +/// exactly like the pre-relm4 full-grid rebuild (a handful of widgets; simpler than row +/// surgery and keeps every derived view consistent). +#[derive(Debug)] +pub struct HostCard { + kind: CardKind, + connecting: bool, } -/// 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, +#[derive(Debug)] +enum CardKind { + Saved { + host: KnownHost, + online: bool, + recent: bool, + library_enabled: bool, + }, + Discovered(DiscoveredHost), } -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); +#[derive(Debug)] +pub enum CardOutput { + Connect(ConnectRequest), + WakeConnect(ConnectRequest), + Pair(ConnectRequest), + SpeedTest(ConnectRequest), + Library(ConnectRequest), + Rename { fp_hex: String, name: String }, + Forget { fp_hex: String, name: String }, + Wake { mac: Vec, addr: String }, +} + +impl HostCard { + fn request(&self) -> ConnectRequest { + match &self.kind { + CardKind::Saved { host: k, .. } => 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 → silent pinned connect. + pair_optional: false, + launch: None, + mac: k.mac.clone(), + }, + CardKind::Discovered(a) => 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 only when the host explicitly opts in with pair=optional. + pair_optional: a.pair == "optional", + launch: None, + mac: a.mac.clone(), + }, + } + } +} + +impl relm4::factory::FactoryComponent for HostCard { + type Init = HostCard; + type Input = (); + type Output = CardOutput; + type CommandOutput = (); + type ParentWidget = gtk::FlowBox; + type Root = gtk::Overlay; + type Widgets = (); + type Index = relm4::factory::DynamicIndex; + + fn init_model( + init: Self::Init, + _index: &Self::Index, + _sender: relm4::FactorySender, + ) -> Self { + init } - pub fn clear_error(&self) { - self.state.banner.set_revealed(false); + fn init_root(&self) -> Self::Root { + gtk::Overlay::new() } - /// 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); + fn init_widgets( + &mut self, + _index: &Self::Index, + overlay: Self::Root, + returned: >k::FlowBoxChild, + sender: relm4::FactorySender, + ) -> Self::Widgets { + let req = self.request(); + + // The shared scaffold: avatar (spinner while connecting) / name / addr / status. + let content = gtk::Box::new(gtk::Orientation::Vertical, 6); + if self.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(&req.name), true); + avatar.set_halign(gtk::Align::Center); + content.append(&avatar); + } + let name_label = gtk::Label::new(Some(&req.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(&format!("{}:{}", req.addr, req.port))); + 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); + + let status = gtk::Box::new(gtk::Orientation::Horizontal, 6); + status.set_halign(gtk::Align::Center); + status.set_margin_top(4); + let pill = |text: &str, class: &str| { + let l = gtk::Label::new(Some(text)); + l.add_css_class("pf-pill"); + l.add_css_class(class); + l + }; + match &self.kind { + CardKind::Saved { host: k, online, .. } => { + // Presence pip + spelled-out state, then the trust pill. + 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") + }); + } + CardKind::Discovered(_) => { + status.append(&if req.pair_optional { + pill("Open", "pf-neutral") + } else { + pill("PIN", "pf-accent") + }); + } + } + content.append(&status); + + overlay.set_child(Some(&content)); + overlay.add_css_class("card"); + overlay.add_css_class("pf-host-card"); + if self.connecting { + returned.set_sensitive(false); + } + + match &self.kind { + CardKind::Saved { + host: k, + online, + recent, + library_enabled, + } => { + if *recent { + overlay.add_css_class("pf-recent"); + } + // Overflow menu (top-right; also on right-click). + let actions = gio::SimpleActionGroup::new(); + let add = |name: &str, out: Box CardOutput>| { + let a = gio::SimpleAction::new(name, None); + let sender = sender.clone(); + a.connect_activate(move |_, _| { + let _ = sender.output(out()); + }); + actions.add_action(&a); + }; + { + let req = req.clone(); + add("pair", Box::new(move || CardOutput::Pair(req.clone()))); + } + { + let req = req.clone(); + add("speed", Box::new(move || CardOutput::SpeedTest(req.clone()))); + } + { + let req = req.clone(); + add("library", Box::new(move || CardOutput::Library(req.clone()))); + } + { + let (fp, name) = (k.fp_hex.clone(), k.name.clone()); + add( + "rename", + Box::new(move || CardOutput::Rename { + fp_hex: fp.clone(), + name: name.clone(), + }), + ); + } + { + let (fp, name) = (k.fp_hex.clone(), k.name.clone()); + add( + "forget", + Box::new(move || CardOutput::Forget { + fp_hex: fp.clone(), + name: name.clone(), + }), + ); + } + { + let (mac, addr) = (k.mac.clone(), k.addr.clone()); + add( + "wake", + Box::new(move || CardOutput::Wake { + mac: mac.clone(), + addr: addr.clone(), + }), + ); + } + 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")); + // An explicit wake only when offline and a MAC is known. + if !online && !k.mac.is_empty() { + menu.append(Some("Wake host"), Some("card.wake")); + } + // Experimental (Preferences gate): browse the host's game library. + if *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); + + // Auto-wake: offline + a known MAC routes to wake-and-wait. + let wake_first = !online && !req.mac.is_empty(); + let sender = sender.clone(); + returned.connect_activate(move |_| { + let _ = sender.output(if wake_first { + CardOutput::WakeConnect(req.clone()) + } else { + CardOutput::Connect(req.clone()) + }); + }); + } + CardKind::Discovered(_) => { + overlay.add_css_class("pf-discovered"); + // Tap-to-connect only (parity with Android's discovered cards). + let sender = sender.clone(); + returned.connect_activate(move |_| { + let _ = sender.output(CardOutput::Connect(req.clone())); + }); + } + } + } +} + +// --- The page component --------------------------------------------------------------------- + +pub struct HostsPage { + adverts: HashMap, + connecting: Option, + settings: Rc>, + saved: FactoryVecDeque, + discovered: FactoryVecDeque, + widgets: PageWidgets, +} + +struct PageWidgets { + stack: gtk::Stack, + banner: adw::Banner, + saved_heading: gtk::Label, + disc_heading: gtk::Label, + searching: gtk::Box, +} + +#[derive(Debug)] +pub enum HostsMsg { + /// A resolved mDNS advert (also the CI scenes' injection path). + Advert(DiscoveredHost), + AdvertRemoved { fullname: String }, + /// Reload the disk store and re-render (fresh pairings, renames, the library gate). + Refresh, + /// Mark the card matching `ConnectRequest::card_key` as connecting; `None` restores. + SetConnecting(Option), + ShowError(String), + ClearError, + ShowAddHost, + /// Forwarded card actions (factory outputs). + Card(CardOutput), +} + +#[derive(Debug)] +pub enum HostsOutput { + Connect(ConnectRequest), + WakeConnect(ConnectRequest), + Pair(ConnectRequest), + SpeedTest(ConnectRequest), + /// With the advertised mgmt port when a live advert carries one. + Library(ConnectRequest, Option), +} + +impl SimpleComponent for HostsPage { + type Init = Rc>; + type Input = HostsMsg; + type Output = HostsOutput; + type Root = adw::NavigationPage; + type Widgets = (); + + fn init_root() -> Self::Root { + adw::NavigationPage::builder() + .title("Punktfunk") + .tag("hosts") + .build() } - /// Feed one advert through the same path the mDNS stream uses (CI screenshot scenes). - pub fn inject_advert(&self, host: DiscoveredHost) { - self.state + fn init( + settings: Self::Init, + page: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + 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() + }; + 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 disc_heading = heading("On this network"); + + let saved = FactoryVecDeque::::builder() + .launch(make_flow()) + .forward(sender.input_sender(), HostsMsg::Card); + let discovered = FactoryVecDeque::::builder() + .launch(make_flow()) + .forward(sender.input_sender(), HostsMsg::Card); + + // A pointer click (and keyboard activate) emits `child-activated` on the + // *FlowBox*, never the child's own `activate` signal — bridge it back to the + // child, where each card wires its connect handler. The re-entrancy flag breaks + // the child-activated ↔ activate ping-pong that otherwise recurses forever + // (a real stack overflow on every card click; see the ignored display test). + for flow in [saved.widget(), discovered.widget()] { + let activating = std::cell::Cell::new(false); + flow.connect_child_activated(move |_, child| { + if activating.replace(true) { + return; + } + child.activate(); + activating.set(false); + }); + } + + // 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_heading); + content.append(saved.widget()); + content.append(&disc_heading); + content.append(&searching); + content.append(discovered.widget()); + + let clamp = adw::Clamp::builder().maximum_size(1100).child(&content).build(); + let scrolled = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .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, 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 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); + 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.add_top_bar(&banner); + toolbar.set_content(Some(&stack)); + page.set_child(Some(&toolbar)); + + // Rebuilt every time the page is shown, so fresh TOFU/pairing entries appear on + // return. + { + let sender = sender.clone(); + page.connect_shown(move |_| sender.input(HostsMsg::Refresh)); + } + + // Stream mDNS adverts into the model; every add/remove re-evaluates both grids. + { + let rx = discovery::browse(); + let sender = sender.clone(); + glib::spawn_future_local(async move { + while let Ok(event) = rx.recv().await { + match event { + DiscoveryEvent::Resolved(h) => sender.input(HostsMsg::Advert(h)), + DiscoveryEvent::Removed { fullname } => { + sender.input(HostsMsg::AdvertRemoved { fullname }) + } + } + } + }); + } + + let mut model = HostsPage { + adverts: HashMap::new(), + connecting: None, + settings, + saved, + discovered, + widgets: PageWidgets { + stack, + banner, + saved_heading, + disc_heading, + searching, + }, + }; + model.rebuild(); + + ComponentParts { + model, + widgets: (), + } + } + + fn update(&mut self, msg: HostsMsg, sender: ComponentSender) { + match msg { + HostsMsg::Advert(h) => { + self.adverts.insert(h.key.clone(), h); + self.rebuild(); + } + HostsMsg::AdvertRemoved { fullname } => { + self.adverts.retain(|_, a| a.fullname != fullname); + self.rebuild(); + } + HostsMsg::Refresh => self.rebuild(), + HostsMsg::SetConnecting(key) => { + self.connecting = key; + self.rebuild(); + } + HostsMsg::ShowError(msg) => { + self.widgets.banner.set_title(&msg); + self.widgets.banner.set_revealed(true); + } + HostsMsg::ClearError => self.widgets.banner.set_revealed(false), + HostsMsg::ShowAddHost => self.add_host_dialog(&sender), + HostsMsg::Card(out) => match out { + CardOutput::Connect(req) => { + let _ = sender.output(HostsOutput::Connect(req)); + } + CardOutput::WakeConnect(req) => { + let _ = sender.output(HostsOutput::WakeConnect(req)); + } + CardOutput::Pair(req) => { + let _ = sender.output(HostsOutput::Pair(req)); + } + CardOutput::SpeedTest(req) => { + let _ = sender.output(HostsOutput::SpeedTest(req)); + } + CardOutput::Library(req) => { + let mgmt = self.mgmt_port_for(&req); + let _ = sender.output(HostsOutput::Library(req, mgmt)); + } + CardOutput::Rename { fp_hex, name } => self.rename_dialog(&sender, &fp_hex, &name), + CardOutput::Forget { fp_hex, name } => self.forget_dialog(&sender, &fp_hex, &name), + CardOutput::Wake { mac, addr } => crate::wol::wake(&mac, addr.parse().ok()), + }, + } + } +} + +impl HostsPage { + /// Re-populate both factories 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. + fn rebuild(&mut self) { + let known = KnownHosts::load(); + // A saved host is ONLINE iff a live advert matches it (fingerprint, or address + // when the advert carries no fp). + 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); + let library_enabled = self.settings.borrow().library_enabled; + + { + let mut saved = self.saved.guard(); + saved.clear(); + for k in &known.hosts { + let online = self.adverts.values().any(|a| matches(k, a)); + // Learn this host's wake MAC(s) from its live advert while it's online. + if let Some(a) = self + .adverts + .values() + .find(|a| matches(k, a) && !a.mac.is_empty()) + { + crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac); + } + saved.push_back(HostCard { + connecting: self.connecting.as_deref() == Some(k.fp_hex.as_str()), + kind: CardKind::Saved { + host: k.clone(), + online, + recent: most_recent.as_deref() == Some(k.fp_hex.as_str()), + library_enabled, + }, + }); + } + } + + // 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> = self .adverts - .borrow_mut() - .insert(host.key.clone(), host); - rebuild(&self.state); + .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))); + let have_disc = !fresh.is_empty(); + { + let mut discovered = self.discovered.guard(); + discovered.clear(); + for a in fresh { + let key = if a.fp_hex.is_empty() { + format!("{}:{}", a.addr, a.port) + } else { + a.fp_hex.clone() + }; + discovered.push_back(HostCard { + connecting: self.connecting.as_deref() == Some(key.as_str()), + kind: CardKind::Discovered(a.clone()), + }); + } + } + + let have_saved = !known.hosts.is_empty(); + let w = &self.widgets; + w.saved_heading.set_visible(have_saved); + self.saved.widget().set_visible(have_saved); + w.disc_heading.set_visible(true); + self.discovered.widget().set_visible(have_disc); + w.searching.set_visible(!have_disc); + w.stack.set_visible_child_name(if have_saved || have_disc { + "grid" + } else { + "empty" + }); } - /// 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 + /// The advertised mgmt port for the host `req` points at, when a matching live + /// advert carries the `mgmt` TXT. + fn mgmt_port_for(&self, req: &ConnectRequest) -> Option { + self.adverts .values() .find(|a| { req.fp_hex @@ -116,620 +691,123 @@ impl HostsUi { }) .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() - }; - 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(); - - // A pointer click (and keyboard activate) emits `child-activated` on the *FlowBox*, never - // the child's own `activate` signal — so bridge it back to the child, where each card wires - // its connect handler (`saved_card`/`discovered_card`). Without this, clicking a card is dead. - // - // `child.activate()` in turn runs `GtkFlowBoxChild`'s own default handler, which re-emits - // `child-activated` on the FlowBox — bouncing straight back into this closure. Unguarded, - // that ping-pong recurses forever and overflows the stack on every single card click/Enter - // (a real crash seen live, not hypothetical); the re-entrancy flag breaks the cycle after - // the one real activation. - for flow in [&saved_flow, &disc_flow] { - let activating = std::cell::Cell::new(false); - flow.connect_child_activated(move |_, child| { - if activating.replace(true) { - return; - } - child.activate(); - activating.set(false); - }); - } - - // 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_heading); - content.append(&saved_flow); - content.append(&disc_heading); - content.append(&searching); - content.append(&disc_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(); - - // 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 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.add_top_bar(&banner); - toolbar.set_content(Some(&stack)); - - let page = adw::NavigationPage::builder() - .title("Punktfunk") - .tag("hosts") - .child(&toolbar) - .build(); - - 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)); - // Learn this host's wake MAC(s) from its live advert while it's online, so we can wake it - // once it sleeps and stops advertising (no-op / no disk write when unchanged). - if let Some(a) = adverts - .values() - .find(|a| matches(k, a) && !a.mac.is_empty()) + /// Rename a saved host — an entry in an alert, then upsert + refresh. + fn rename_dialog(&self, sender: &ComponentSender, 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"); { - crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac); - } - 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, - mac: k.mac.clone(), - }; - - // 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)), - ); - } - { - // Explicit "just wake it" (the tap-to-connect already auto-wakes an offline host). - let mac = k.mac.clone(); - let addr = k.addr.clone(); - add( - "wake", - Box::new(move || crate::wol::wake(&mac, addr.parse().ok())), - ); - } - 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")); - // Offer an explicit wake only when the host is offline and we actually have a MAC to target. - if !online && !k.mac.is_empty() { - menu.append(Some("Wake host"), Some("card.wake")); - } - // 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(); - let on_wake_connect = state.cbs.on_wake_connect.clone(); - // Auto-wake: if the host wasn't advertising when this card was built and we have a MAC, route to - // the wake-and-wait flow (send a magic packet, poll mDNS until it's up — re-keying a new DHCP IP — - // then connect). Otherwise a plain connect. A host that's genuinely off then times out as before. - let wake_first = !online && !req.mac.is_empty(); - child.connect_activate(move |_| { - if wake_first { - on_wake_connect(req.clone()); - } else { - 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, - mac: a.mac.clone(), - }; - - 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()) + let sender = sender.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; } - _ => ( - 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, - mac: Vec::new(), + 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(); + } + sender.input(HostsMsg::Refresh); }); - }); + } + dialog.present(Some(&self.widgets.stack)); + } + + /// Forget this host (drops the pinned fingerprint — a later connect re-pairs). + fn forget_dialog(&self, sender: &ComponentSender, 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 sender = sender.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(); + sender.input(HostsMsg::Refresh); + }); + } + dialog.present(Some(&self.widgets.stack)); + } + + /// "+": name (optional) / address / port. Submit runs the normal trust gate. + fn add_host_dialog(&self, sender: &ComponentSender) { + 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 sender = sender.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; else the field. + 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(); + let _ = sender.output(HostsOutput::Connect(ConnectRequest { + name: if name.is_empty() { addr.clone() } else { name }, + addr, + port, + fp_hex: None, + // Manual entry carries no advertised policy — never TOFU-eligible. + pair_optional: false, + launch: None, + mac: Vec::new(), + })); + }); + } + dialog.present(Some(&self.widgets.stack)); } - dialog.present(Some(&state.stack)); } #[cfg(test)] @@ -738,11 +816,10 @@ mod tests { use std::cell::Cell; use std::rc::Rc; - // Reproduces the exact FlowBox/FlowBoxChild wiring from `new()`: `child-activated` bridges - // to `child.activate()`, whose own default handler re-emits `child-activated` on the - // FlowBox — that ping-pong recursed forever (stack overflow on every host-card click/Enter) - // until the re-entrancy guard was added. This exercises the *real* GTK signal cycle, not a - // simulation of it, so it fails the same way the shipped bug did if the guard regresses. + // Reproduces the exact FlowBox/FlowBoxChild wiring from `init()`: `child-activated` + // bridges to `child.activate()`, whose own default handler re-emits + // `child-activated` — that ping-pong recursed forever (stack overflow on every + // host-card click/Enter) until the re-entrancy guard was added. #[test] #[ignore = "needs a Wayland/X display"] fn flow_box_activation_bridge_does_not_recurse() { @@ -769,15 +846,8 @@ mod tests { child.connect_activate(move |_| fired.set(fired.get() + 1)); } - // What a pointer click with `activate_on_single_click` does internally: emit - // `child-activated` directly on the FlowBox. A regression here overflows the stack - // instead of returning. flow.emit_by_name::<()>("child-activated", &[&child]); - assert_eq!( - fired.get(), - 1, - "the per-card handler should fire exactly once" - ); + assert_eq!(fired.get(), 1, "the per-card handler should fire exactly once"); } } diff --git a/clients/linux/src/ui_library.rs b/clients/linux/src/ui_library.rs index e9600ccc..62c278e5 100644 --- a/clients/linux/src/ui_library.rs +++ b/clients/linux/src/ui_library.rs @@ -5,12 +5,13 @@ //! 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::app::{AppModel, AppMsg}; use crate::library::{self, GameEntry}; use crate::trust; use crate::ui_hosts::ConnectRequest; use adw::prelude::*; use gtk::{gdk, glib}; +use relm4::prelude::*; use std::cell::{Cell, RefCell}; use std::collections::{HashMap, VecDeque}; use std::rc::Rc; @@ -19,7 +20,10 @@ use std::rc::Rc; /// 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, + sender: ComponentSender, + identity: (String, String), + /// The advertised mgmt port when the host was live at open time (else the default). + mgmt_port: u16, /// The host this library belongs to — cards clone it and add `launch`. req: ConnectRequest, stack: gtk::Stack, @@ -34,21 +38,29 @@ struct State { 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); +/// Open the library page for a saved host and start the fetch. `mgmt_port` comes from +/// the live mDNS `mgmt` TXT when the host is advertising (the hosts page resolves it). +pub fn open( + app: &AppModel, + sender: &ComponentSender, + req: ConnectRequest, + mgmt_port: Option, +) { + let state = build(&app.nav, app.identity.clone(), sender, req, mgmt_port); 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, + nav: &adw::NavigationView, + identity: (String, String), + sender: &ComponentSender, req: ConnectRequest, games: Vec, art: Vec<(String, gdk::Texture)>, ) { - let state = build(app.clone(), req); + let state = build(nav, identity, sender, req, None); state.mock.set(true); state.art.borrow_mut().extend(art); if games.is_empty() { @@ -60,7 +72,13 @@ pub fn open_mock( } /// Build the page (loading / error / empty / grid states in a stack) and push it. -fn build(app: Rc, req: ConnectRequest) -> Rc { +fn build( + nav: &adw::NavigationView, + identity: (String, String), + sender: &ComponentSender, + req: ConnectRequest, + mgmt_port: Option, +) -> Rc { let flow = gtk::FlowBox::builder() .selection_mode(gtk::SelectionMode::None) .activate_on_single_click(true) @@ -142,7 +160,9 @@ fn build(app: Rc, req: ConnectRequest) -> Rc { .build(); let state = Rc::new(State { - app: app.clone(), + sender: sender.clone(), + identity, + mgmt_port: mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT), req, stack, flow, @@ -159,20 +179,10 @@ fn build(app: Rc, req: ConnectRequest) -> Rc { let state = state.clone(); retry.connect_clicked(move |_| load(&state)); } - app.nav.push(&page); + 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) { @@ -180,9 +190,9 @@ fn load(state: &Rc) { return; // screenshot scene renders injected entries only } state.stack.set_visible_child_name("loading"); - let port = mgmt_port(state); + let port = state.mgmt_port; let addr = state.req.addr.clone(); - let identity = state.app.identity.clone(); + let identity = state.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() @@ -268,10 +278,10 @@ fn game_card(state: &Rc, game: &GameEntry) -> gtk::FlowBoxChild { let child = gtk::FlowBoxChild::new(); child.set_child(Some(&card)); - let app = state.app.clone(); + let sender = state.sender.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.connect_activate(move |_| sender.input(AppMsg::Connect(req.clone()))); child } @@ -279,8 +289,7 @@ fn game_card(state: &Rc, game: &GameEntry) -> gtk::FlowBoxChild { /// 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 base = library::base_url(&state.req.addr, state.mgmt_port); let jobs: VecDeque<(String, Vec)> = { let cache = state.art.borrow(); games @@ -293,7 +302,7 @@ fn load_art(state: &Rc, games: &[GameEntry]) { if jobs.is_empty() { return; } - let identity = state.app.identity.clone(); + let identity = state.identity.clone(); let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32); let rx = library::spawn_art_fetch(base, identity, pin, jobs); let weak = Rc::downgrade(state); diff --git a/clients/linux/src/ui_stream.rs b/clients/linux/src/ui_stream.rs deleted file mode 100644 index e6766d86..00000000 --- a/clients/linux/src/ui_stream.rs +++ /dev/null @@ -1,1108 +0,0 @@ -//! The stream page: decoded frames into a `GtkGraphicsOffload`-wrapped picture, local -//! input captured and forwarded on the wire contract. -//! -//! Input capture is a deliberate, reversible STATE (Moonlight-style, mirroring the Swift -//! client): engaged when the stream starts and when the user clicks into the video (that -//! click is suppressed toward the host); released by Ctrl+Alt+Shift+Q (toggles) or focus -//! loss — held keys/buttons are flushed host-side on release so nothing sticks down. -//! While captured the local cursor is hidden (the host renders its own) and compositor -//! shortcuts are inhibited (configurable); while released nothing is forwarded and the -//! HUD says how to recapture. -//! -//! Keys are hardware keycodes (evdev + 8 on Wayland) → VK via `keymap`, layout- -//! independent. Mouse is absolute (`MouseMoveAbs` scaled into the negotiated mode through -//! the letterbox transform, surface size packed in `flags`) — pointer-lock relative -//! capture is the stage-2 presenter's job. F11 toggles fullscreen locally. - -use crate::keymap; -use crate::session::Stats; -use crate::video::{DecodedFrame, DecodedImage}; -use adw::prelude::*; -use gtk::{gdk, glib}; -use punktfunk_core::client::NativeClient; -use punktfunk_core::input::{InputEvent, InputKind}; -use std::cell::{Cell, RefCell}; -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, - /// The frame consumer's share of the stats window (end-to-end percentiles + the - /// `display` stage) — written there each 1 s window, folded into the OSD on each - /// `Stats` event. - presented: Rc, - /// The stream is HDR (PQ) right now — set by the frame consumer from each frame's - /// signaling (the host can flip SDR↔HDR mid-session, in-band). - hdr: Rc>, - /// `clock_offset_ns == 0`: the skew handshake didn't run (or same host) — the - /// end-to-end line carries the `(same-host clock)` flag (spec clock rules). - same_host: bool, - /// `W×H@Hz` for the OSD's first line — fixed at connect, per-session. - mode_line: String, -} - -/// Presenter-side window results (design/stats-unification.md): end-to-end = -/// capture→displayed measured directly (p50 + p95), `display` stage = decoded→displayed -/// p50. All ms, refreshed once per 1 s window by the frame consumer. -#[derive(Default)] -struct PresentedStats { - e2e_p50_ms: Cell, - e2e_p95_ms: Cell, - display_ms: Cell, -} - -impl StreamPage { - /// Render the canonical unified-stats OSD (design/stats-unification.md — Linux - /// endpoint is paintable-set, headline reads `capture→displayed`). - pub fn update_stats(&self, s: Stats) { - let mut line1 = format!("{} · {:.0} fps · {:.1} Mb/s", self.mode_line, s.fps, s.mbps); - // Which decoder actually ran this window (vaapi/software) — tracks a fallback. - if !s.decoder.is_empty() { - line1.push_str(" · "); - line1.push_str(s.decoder); - } - if self.hdr.get() { - line1.push_str(" · HDR"); - } - // The equation line: split `host+network` into `host + network` when the host - // reported per-AU timings (0xCF, stats Phase 2); the combined stage otherwise. - let equation = if s.split { - format!( - "= host {:.1} + network {:.1} + decode {:.1} + display {:.1}", - s.host_ms, - s.net_ms, - s.decode_ms, - self.presented.display_ms.get(), - ) - } else { - format!( - "= host+network {:.1} + decode {:.1} + display {:.1}", - s.host_net_ms, - s.decode_ms, - self.presented.display_ms.get(), - ) - }; - let mut text = format!( - "{line1}\n\ - end-to-end {:.1} ms p50 · {:.1} p95 · capture→displayed{}\n\ - {equation}", - self.presented.e2e_p50_ms.get(), - self.presented.e2e_p95_ms.get(), - if self.same_host { - " (same-host clock)" - } else { - "" - }, - ); - // Counters — only rendered when nonzero this window. - if s.lost > 0 { - text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct)); - } - self.stats_label.set_text(&text); - } -} - -/// 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, - /// Shared with the session pump: the presenter raises it when hardware frames can't - /// be displayed (GL converter init failed / dmabuf import rejected) and the pump - /// demotes the decoder to software. - pub force_software: Arc, - /// 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, - /// Gaming-Mode launch (`--fullscreen` / Deck env): build the page with NO header bar - /// at all. gamescope displays the window fullscreen but does not reliably ACK the - /// xdg_toplevel fullscreen state back, so anything keyed on `is_fullscreen()` (the - /// reveal-on-notify chrome hiding) may never fire — the title bar would stay drawn - /// over the stream. Chrome-less by construction cannot regress that way. - pub chromeless: bool, - /// A controller is connected right now — the capture hint mentions the escape chord. - /// (Chromeless implies a controller-first device, so the chord shows there regardless.) - pub pad_connected: bool, - pub title: String, -} - -fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) { - let _ = connector.send_input(&InputEvent { - kind, - _pad: [0; 3], - code, - x, - y, - flags, - }); -} - -/// Forward an absolute pointer position: widget coordinates → video pixels through the -/// Contain-fit letterbox. `flags` packs the coordinate-space size (`(w << 16) | h`, the -/// same contract as touch) — the host normalizes against it before mapping into the EIS -/// region; without it the event is dropped. -fn send_abs(widget: &impl IsA, connector: &NativeClient, x: f64, y: f64) { - let w = widget.as_ref(); - let mode = connector.mode(); - let (ww, wh) = (w.width().max(1) as f64, w.height().max(1) as f64); - let (vw, vh) = (mode.width.max(1) as f64, mode.height.max(1) as f64); - let scale = (ww / vw).min(wh / vh); - let (ox, oy) = ((ww - vw * scale) / 2.0, (wh - vh * scale) / 2.0); - let px = (((x - ox) / scale).round()).clamp(0.0, vw - 1.0) as i32; - let py = (((y - oy) / scale).round()).clamp(0.0, vh - 1.0) as i32; - let flags = (mode.width << 16) | (mode.height & 0xffff); - send(connector, InputKind::MouseMoveAbs, 0, px, py, flags); -} - -/// The capture state shared by every input controller on the page. -struct Capture { - connector: Arc, - window: adw::ApplicationWindow, - /// Held WEAKLY. Every input controller + the frame-clock tick are added to this overlay - /// and each captures `Rc`; a strong ref back here would close the cycle - /// `overlay → controller → Rc → overlay` that GTK can't collect, leaking the - /// whole stream subtree AND the `Arc` (so `NativeClient::Drop` never runs) - /// on every session end — unbounded growth across the reconnects a Deck does constantly. - /// The live widget tree owns the overlay for the session's lifetime; upgrade at use. - overlay: glib::WeakRef, - 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>, - /// Fractional wheel remainder per axis (x, y), in 120-unit WHEEL_DELTA space. Precision - /// scroll surfaces — the Deck trackpad, hi-res wheels, two-finger touchpad — deliver - /// sub-unit deltas; truncating each event drops the tail. Carry it here instead. - scroll_acc: Cell<(f64, f64)>, -} - -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() { - if let Some(overlay) = self.overlay.upgrade() { - send_abs(&overlay, &self.connector, x, y); - } - } - } - - fn engage(&self) { - if self.captured.replace(true) { - return; - } - if let Some(overlay) = self.overlay.upgrade() { - overlay.set_cursor(gdk::Cursor::from_name("none", None).as_ref()); - } - self.hint.set_visible(false); - if self.inhibit_shortcuts { - if let Some(tl) = self - .window - .surface() - .and_then(|s| s.downcast::().ok()) - { - tl.inhibit_system_shortcuts(None::<&gdk::Event>); - } - } - } - - fn release(&self) { - if !self.captured.replace(false) { - return; - } - if let Some(overlay) = self.overlay.upgrade() { - 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() - .and_then(|s| s.downcast::().ok()) - { - tl.restore_system_shortcuts(); - } - // Flush everything held so nothing sticks down on the host. - for vk in self.held_keys.borrow_mut().drain() { - send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0); - } - for b in self.held_buttons.borrow_mut().drain() { - send(&self.connector, InputKind::MouseButtonUp, b, 0, 0, 0); - } - } -} - -/// How long the capture hint is flashed at stream start (seconds) before it auto-hides — long -/// enough to read the release/disconnect keys, short enough to get out of the way of the game. -const START_HINT_SECS: u32 = 6; - -pub fn new(args: StreamPageArgs) -> StreamPage { - let StreamPageArgs { - window, - connector, - frames, - force_software, - clock_offset_ns, - escape_rx, - disconnect_rx, - stop, - inhibit_shortcuts, - show_stats, - chromeless, - pad_connected, - title, - } = args; - let w = build_widgets(&window, &title, chromeless, pad_connected); - w.stats_label.set_visible(show_stats); - - // OSD line-1 facts, fixed for the session (the mode is negotiated per-session). - let mode = connector.mode(); - let mode_line = format!("{}×{}@{}", mode.width, mode.height, mode.refresh_hz); - // Offset 0 = the host didn't answer the skew handshake / same host — flagged on the - // end-to-end line so an uncorrected cross-machine number is never shown silently. - let same_host = clock_offset_ns == 0; - - let capture = Rc::new(Capture { - connector, - window: window.clone(), - overlay: w.overlay.downgrade(), - 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()), - scroll_acc: Cell::new((0.0, 0.0)), - }); - - let presented = Rc::new(PresentedStats::default()); - let hdr = Rc::new(Cell::new(false)); - spawn_frame_consumer( - &w.picture, - frames, - force_software, - clock_offset_ns, - presented.clone(), - hdr.clone(), - ); - let key_controller = attach_keyboard(&window, &capture, &stop, &w.stats_label); - attach_mouse(&w.overlay, &capture); - attach_scroll(&w.overlay, &capture); - if !chromeless { - attach_edge_reveal(&w.toolbar, &w.overlay, &window, &capture); - } - let active_handler = attach_capture_lifecycle(&w.overlay, &window, &capture); - // Flash the shortcut hint for a few seconds at stream start: capture engages on map (which - // hides the hint), so without this the release/disconnect keys are never shown until the user - // first releases. Connected after the lifecycle handler so it wins the map race; the timeout - // only re-hides if input is still captured, so a release during the flash keeps the hint up. - // (Parity with the Windows client's stream-start banner.) - { - let cap = capture.clone(); - let hint = w.hint.clone(); - w.overlay.connect_map(move |_| { - hint.set_visible(true); - let cap = cap.clone(); - let hint = hint.clone(); - glib::timeout_add_seconds_local_once(START_HINT_SECS, move || { - if cap.captured.get() { - hint.set_visible(false); - } - }); - }); - } - let escape_future = spawn_escape_watch(&window, &capture, escape_rx, &w.fs_hint, chromeless); - let disconnect_future = spawn_disconnect_watch(&window, &capture, &stop, disconnect_rx); - wire_teardown( - &w.page, - &window, - &stop, - (w.fs_handler, active_handler), - key_controller, - escape_future, - disconnect_future, - ); - - StreamPage { - page: w.page, - stats_label: w.stats_label, - presented, - hdr, - same_host, - mode_line, - } -} - -/// 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, - /// The transient chord/fullscreen-exit hint — the escape watch re-flashes it in - /// chromeless mode. - fs_hint: gtk::Label, - overlay: gtk::Overlay, - toolbar: adw::ToolbarView, - 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. -/// `chromeless` (Gaming Mode) builds NO header bar at all — see `StreamPageArgs`. -fn build_widgets( - window: &adw::ApplicationWindow, - title: &str, - chromeless: bool, - pad_connected: bool, -) -> PageWidgets { - let picture = gtk::Picture::new(); - picture.set_content_fit(gtk::ContentFit::Contain); - - // The offload path: with a dmabuf-backed texture (stage 1.5) this becomes a - // subsurface the compositor can scan out directly; with memory textures it is a - // no-op wrapper. Black letterboxing keeps fullscreen scanout-eligible. - let offload = gtk::GraphicsOffload::new(Some(&picture)); - offload.set_black_background(true); - // Whether the raw video dmabuf may be handed to the compositor as a subsurface. - // Under gamescope (chromeless) default OFF: a subsurface makes the COMPOSITOR do the - // NV12→RGB conversion, and gamescope's matrix/range choice for it is outside our - // control (off-colours reported on the Deck) — GTK compositing it itself applies the - // stream's own BT.709-narrow color state. `PUNKTFUNK_OFFLOAD=1|0` overrides either - // way, which also makes the colour question bisectable in one run: offload-off heals → - // compositor conversion; still off → GTK/Mesa import (then try PUNKTFUNK_DECODER=software). - let offload_on = match std::env::var("PUNKTFUNK_OFFLOAD").ok().as_deref() { - Some("0") => false, - Some(_) => true, - None => !chromeless, - }; - if !offload_on { - offload.set_enabled(gtk::GraphicsOffloadEnabled::Disabled); - tracing::info!("graphics offload disabled — GTK composites the video itself"); - } - - let stats_label = gtk::Label::new(None); - stats_label.add_css_class("osd"); - stats_label.add_css_class("numeric"); - stats_label.set_halign(gtk::Align::Start); - stats_label.set_valign(gtk::Align::Start); - stats_label.set_margin_start(12); - stats_label.set_margin_top(12); - - // The capture hint speaks the input devices actually present: on a controller-first - // device (chromeless) or with a pad connected it must surface the chord — keyboard-only - // text on a Deck told the user nothing they could press. - let hint = gtk::Label::new(Some(if chromeless { - "Tap the stream to capture input · hold L1 + R1 + Start + Select to leave" - } else if pad_connected { - "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave" - } else { - "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats · F11 fullscreen" - })); - hint.add_css_class("osd"); - hint.set_halign(gtk::Align::Center); - hint.set_valign(gtk::Align::End); - hint.set_margin_bottom(24); - hint.set_visible(false); - - // Flashed when entering fullscreen — the exit affordances once the header bar is - // hidden (F11 on a keyboard; the top-edge pointer reveal for mouse/trackpad-only - // devices; the L1+R1+Start+Select chord on a controller). Gaming Mode has no F11, - // no header to reveal, and Steam owns window management — only the chord applies. - let fs_hint = gtk::Label::new(Some(if chromeless { - "Hold L1 + R1 + Start + Select — leave the stream" - } else { - "F11 · mouse to the top edge · L1 + R1 + Start + Select — exit fullscreen (hold to disconnect)" - })); - fs_hint.add_css_class("osd"); - fs_hint.set_halign(gtk::Align::Center); - fs_hint.set_valign(gtk::Align::Start); - fs_hint.set_margin_top(12); - fs_hint.set_visible(false); - - let overlay = gtk::Overlay::new(); - overlay.set_child(Some(&offload)); - overlay.add_overlay(&stats_label); - overlay.add_overlay(&hint); - overlay.add_overlay(&fs_hint); - overlay.set_focusable(true); - - let toolbar = adw::ToolbarView::new(); - if !chromeless { - let header = adw::HeaderBar::new(); - let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic"); - fullscreen_btn.set_tooltip_text(Some("Fullscreen (F11)")); - { - let window = window.clone(); - fullscreen_btn.connect_clicked(move |_| { - if window.is_fullscreen() { - window.unfullscreen(); - } else { - window.fullscreen(); - } - }); - } - header.pack_end(&fullscreen_btn); - toolbar.add_top_bar(&header); - } else { - // No header exists to hide, and gamescope may never ACK fullscreen — flash the - // chord hint when the stream maps instead of on the fullscreened notify. - let fs_hint = fs_hint.clone(); - overlay.connect_map(move |_| { - fs_hint.set_visible(true); - let fs_hint = fs_hint.clone(); - glib::timeout_add_seconds_local_once(4, move || fs_hint.set_visible(false)); - }); - } - toolbar.set_content(Some(&overlay)); - // Fullscreen = the stream and nothing else. (Window handlers are disconnected when - // the page dies — the window outlives every session.) - let fs_handler = { - let toolbar = toolbar.clone(); - let fs_hint = fs_hint.clone(); - window.connect_fullscreened_notify(move |w| { - let fs = w.is_fullscreen(); - toolbar.set_reveal_top_bars(!fs); - if chromeless { - return; // the map handler above owns the hint; there is no bar to reveal - } - if fs { - fs_hint.set_visible(true); - let fs_hint = fs_hint.clone(); - glib::timeout_add_seconds_local_once(4, move || fs_hint.set_visible(false)); - } else { - fs_hint.set_visible(false); - } - }) - }; - - let page = adw::NavigationPage::builder() - .title(title) - .tag("stream") - .child(&toolbar) - .build(); - - PageWidgets { - picture, - stats_label, - hint, - fs_hint, - overlay, - toolbar, - page, - fs_handler, - } -} - -/// Fullscreen chrome recovery for pointer-only devices (a Deck desktop has no F11): while -/// fullscreen and NOT captured, bumping the pointer against the top edge reveals the header -/// bar (back button, fullscreen toggle); moving back into the stream hides it again. While -/// captured the pointer belongs to the host — nothing reveals, and a still-revealed bar is -/// re-hidden on the first captured movement (release capture first: Ctrl+Alt+Shift+Q). -fn attach_edge_reveal( - toolbar: &adw::ToolbarView, - overlay: >k::Overlay, - window: &adw::ApplicationWindow, - capture: &Rc, -) { - let motion = gtk::EventControllerMotion::new(); - let toolbar = toolbar.clone(); - let window = window.clone(); - let cap = capture.clone(); - motion.connect_motion(move |_, _x, y| { - if !window.is_fullscreen() { - return; // windowed chrome is the fullscreened-notify handler's business - } - if cap.captured.get() { - if toolbar.reveals_top_bars() { - toolbar.set_reveal_top_bars(false); - } - return; - } - if y <= 2.0 { - toolbar.set_reveal_top_bars(true); - } else if y > 4.0 && toolbar.reveals_top_bars() { - // Once revealed the content sits below the bar, so y stays small while the - // pointer hovers the boundary; anything deeper means the user moved back in. - toolbar.set_reveal_top_bars(false); - } - }); - overlay.add_controller(motion); -} - -/// 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 `displayed` measurement point (design/stats-unification.md): each paintable -/// set stamps the local wall clock, yielding end-to-end = capture→displayed (host-clock -/// corrected via `clock_offset_ns`, p50+p95, measured directly) and the client-local -/// `display` stage = decoded→displayed. This is capture→paintable-SET — GTK's own -/// present adds one compositor cycle after this. The 1 s window results land on the -/// stats OSD (via `PresentedStats`) and in a "present window" debug line for headless -/// validation. -/// One-entry cache of `ColorDesc` → `GdkColorState` (signaling changes at most on an -/// SDR↔HDR flip, never per frame). -#[derive(Default)] -struct ColorStateCache(Option<(crate::video::ColorDesc, Option)>); - -impl ColorStateCache { - /// The color state for a frame's signaling. `rgb` = the pixels are already full-range - /// RGB (the CPU path — only transfer + primaries remain meaningful); else YUV, where - /// H.273 "unspecified" (2) fills in as BT.709 limited, the host's SDR default. `None` - /// = GDK can't represent the combo — the caller's default (sRGB) applies, which - /// matches the pre-color-management behavior. - fn get(&mut self, desc: crate::video::ColorDesc, rgb: bool) -> Option { - if let Some((cached, state)) = &self.0 { - if *cached == desc { - return state.clone(); - } - } - let def = |v: u8, d: u32| if v == 2 { d } else { u32::from(v) }; - let cicp = gdk::CicpParams::new(); - if rgb { - cicp.set_color_primaries(def(desc.primaries, 1)); - cicp.set_transfer_function(def(desc.transfer, 13)); // 13 = sRGB - cicp.set_matrix_coefficients(0); // identity — the matrix is already undone - cicp.set_range(gdk::CicpRange::Full); - } else { - cicp.set_color_primaries(def(desc.primaries, 1)); - cicp.set_transfer_function(def(desc.transfer, 1)); - cicp.set_matrix_coefficients(def(desc.matrix, 1)); - cicp.set_range(if desc.full_range { - gdk::CicpRange::Full - } else { - gdk::CicpRange::Narrow - }); - } - let state = cicp.build_color_state().ok(); - // One line per signaling change — the on-glass colour bisect reads this to tell - // "state applied" from "GDK fell back to its YUV default (BT.601)". - match &state { - Some(_) => tracing::info!(?desc, rgb, "colour signaling → GDK color state"), - None => tracing::warn!( - ?desc, - rgb, - "GDK can't represent this colour signaling — using default (YUV: BT.601)" - ), - } - self.0 = Some((desc, state.clone())); - state - } -} - -/// How hardware (dmabuf) frames reach the screen. -#[derive(PartialEq, Clone, Copy)] -enum HwPresent { - /// Hand the NV12 dmabuf straight to `GdkDmabufTexture` — GTK (or the compositor via - /// offload) imports + converts. The desktop default: subsurface/scan-out eligible. - Direct, - /// Convert in-process first (`video_gl`): own EGL import + own YUV→RGB shader → RGBA - /// `GdkGLTexture`. The Steam Deck default — GTK's tiled-NV12 import is broken there - /// (Mesa ≥ 25.1 tiled VCN export), and this is the Moonlight-proven route around it. - Gl, -} - -impl HwPresent { - fn pick() -> HwPresent { - match std::env::var("PUNKTFUNK_PRESENT").ok().as_deref() { - Some("direct") => HwPresent::Direct, - Some("gl") => HwPresent::Gl, - _ if crate::gamepad::is_steam_deck() => HwPresent::Gl, - _ => HwPresent::Direct, - } - } -} - -fn spawn_frame_consumer( - picture: >k::Picture, - frames: async_channel::Receiver, - force_software: Arc, - clock_offset_ns: i64, - presented_stats: Rc, - hdr: Rc>, -) { - let picture = picture.downgrade(); - // The colour state follows the FRAMES' own signaling (the Windows host switches an HDR - // desktop to BT.2020 PQ in-band while the Welcome still says SDR): unspecified falls - // back to BT.709 limited — without an explicit state GDK would convert NV12 dmabufs - // with the (BT.601) dmabuf default. Cached per distinct signaling; a change mid-stream - // (SDR↔HDR flip) just rebuilds once. - let mut yuv_state = ColorStateCache::default(); - let mut rgb_state = ColorStateCache::default(); - let hw_present = HwPresent::pick(); - // Lazy (first dmabuf frame) so software-decode sessions never touch EGL. `Err` after - // a failed init = don't retry every frame. - let mut gl_conv: Option> = None; - let mut gl_fails = 0u32; - glib::spawn_future_local(async move { - // Window samples (µs): end-to-end capture→displayed (host-clock corrected) and - // the client-local display stage decoded→displayed. - let mut win_e2e_us: Vec = Vec::with_capacity(256); - let mut win_disp_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) => hdr.set(c.color.is_pq()), - DecodedImage::Dmabuf(d) => hdr.set(d.color.is_pq()), - } - match f.image { - DecodedImage::Cpu(c) => { - let bytes = glib::Bytes::from_owned(c.rgba); - // swscale undid the YUV matrix (full-range RGB) — but a PQ/BT.2020 - // stream keeps transfer + primaries baked in, so tag the texture and - // let GTK tone-map. Plain SDR keeps the untagged (sRGB) fast path. - let tagged = (c.color.is_pq() || c.color.primaries == 9) - .then(|| rgb_state.get(c.color, true)) - .flatten(); - let tex: gdk::Texture = if let Some(state) = tagged { - gdk::MemoryTextureBuilder::new() - .set_width(c.width as i32) - .set_height(c.height as i32) - .set_format(gdk::MemoryFormat::R8g8b8a8) - .set_bytes(Some(&bytes)) - .set_stride(c.stride) - .set_color_state(&state) - .build() - .upcast() - } else { - gdk::MemoryTexture::new( - c.width as i32, - c.height as i32, - gdk::MemoryFormat::R8g8b8a8, - &bytes, - c.stride, - ) - .upcast() - }; - picture.set_paintable(Some(&tex)); - presented = true; - } - DecodedImage::Dmabuf(d) if hw_present == HwPresent::Gl => { - // In-process conversion (see `HwPresent::Gl`). Init once; a failed - // init or a streak of convert failures demotes the DECODER to - // software via the shared flag — never fall back to the direct path - // here, it's the known-broken one on this hardware. - let conv = gl_conv.get_or_insert_with(|| { - crate::video_gl::GlConverter::new(&picture).map_err(|e| { - tracing::warn!(error = %format!("{e:#}"), - "GL presenter unavailable — demoting to software decode"); - }) - }); - match conv { - Ok(c) => { - let color = d.color; - match c.convert(d, rgb_state.get(color, true).as_ref()) { - Ok(tex) => { - gl_fails = 0; - picture.set_paintable(Some(&tex)); - presented = true; - } - Err(e) => { - gl_fails += 1; - tracing::warn!(error = %format!("{e:#}"), fails = gl_fails, - "GL convert failed"); - if gl_fails >= 3 { - force_software.store(true, Ordering::Relaxed); - } - } - } - } - Err(()) => force_software.store(true, Ordering::Relaxed), - } - } - 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(yuv_state.get(d.color, false).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; - } - 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"); - } - } - } - } - // The `displayed` stamp: end-to-end = capture→displayed host-clock corrected - // (same clamp as the session's stage windows); display = decoded→displayed, - // single clock, no skew. - if presented { - let displayed_ns = crate::session::now_ns(); - let e2e = (displayed_ns as i128 + clock_offset_ns as i128 - f.pts_ns as i128).max(0) - as u64; - if e2e > 0 && e2e < 10_000_000_000 { - win_e2e_us.push(e2e / 1000); - } - win_disp_us.push(displayed_ns.saturating_sub(f.decoded_ns) / 1000); - } - if win_start.elapsed() >= Duration::from_secs(1) { - let frames = win_e2e_us.len(); - let (e2e_p50, e2e_p95) = crate::session::window_percentiles(&mut win_e2e_us); - let (disp_p50, _) = crate::session::window_percentiles(&mut win_disp_us); - tracing::debug!( - frames, - e2e_p50_us = e2e_p50, - e2e_p95_us = e2e_p95, - display_p50_us = disp_p50, - "present window" - ); - presented_stats.e2e_p50_ms.set(e2e_p50 as f32 / 1000.0); - presented_stats.e2e_p95_ms.set(e2e_p95 as f32 / 1000.0); - presented_stats.display_ms.set(disp_p50 as f32 / 1000.0); - win_e2e_us.clear(); - win_disp_us.clear(); - win_start = Instant::now(); - } - } - }); -} - -/// 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. -/// -/// The controller lives on the **window**, not the stream overlay: a `NavigationView` push -/// followed by `window.fullscreen()` hands keyboard focus to the pushed page's header back -/// button (a sibling of the overlay), so an overlay-scoped key controller never sees a key and -/// every chord — plus all gameplay key forwarding — is silently dropped until the user clicks -/// the stream. The window is always on the key-propagation path regardless of which child holds -/// focus. Returned so `wire_teardown` can remove it when the page goes away (otherwise the -/// chords would keep firing app-wide against a dead session). -fn attach_keyboard( - window: &adw::ApplicationWindow, - capture: &Rc, - stop: &Arc, - stats: >k::Label, -) -> gtk::EventControllerKey { - 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 { - tracing::info!( - captured = cap.captured.get(), - "chord: Ctrl+Alt+Shift+Q (release/engage)" - ); - 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 { - tracing::info!("chord: Ctrl+Alt+Shift+D (disconnect) — releasing capture + quitting"); - cap.release(); - // Deliberate user exit → close with QUIT_CLOSE_CODE so the host tears the session down - // immediately instead of holding the keep-alive linger for a reconnect. - cap.connector.disconnect_quit(); - 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(); - } - 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); - } - } - }); - window.add_controller(key.clone()); - key -} - -/// 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 (which `Capture` now holds - // only weakly, so it truly can), taking its `Capture` ref with it — no explicit 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| { - if let Some(overlay) = cap.overlay.upgrade() { - 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. libei's discrete scroll is 120-based too. Accumulate the - // fractional remainder so precision-scroll sub-unit deltas aren't lost. - let (mut ax, mut ay) = cap.scroll_acc.get(); - ay += -dy * 120.0; - ax += dx * 120.0; - let vy = ay.trunc() as i32; - if vy != 0 { - ay -= f64::from(vy); - send(&cap.connector, InputKind::MouseScroll, 0, vy, 0, 0); - } - let vx = ax.trunc() as i32; - if vx != 0 { - ax -= f64::from(vx); - send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0); - } - cap.scroll_acc.set((ax, ay)); - 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 { - { - let cap = capture.clone(); - overlay.connect_map(move |w| { - w.grab_focus(); - cap.engage(); - }); - } - { - 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). In chromeless mode there is nothing visible to release INTO — a quick press -/// re-flashes the hold-to-leave hint instead, so an experimenting user learns the hold. -/// 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<()>, - fs_hint: >k::Label, - chromeless: bool, -) -> glib::JoinHandle<()> { - let window = window.clone(); - let cap = capture.clone(); - let fs_hint = fs_hint.clone(); - glib::spawn_future_local(async move { - while escape_rx.recv().await.is_ok() { - if window.is_fullscreen() { - window.unfullscreen(); - } - cap.release(); - if chromeless { - fs_hint.set_visible(true); - let fs_hint = fs_hint.clone(); - glib::timeout_add_seconds_local_once(4, move || fs_hint.set_visible(false)); - } - } - }) -} - -/// 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(); - // Deliberate user exit (the controller escape chord) → QUIT_CLOSE_CODE, host skips linger. - cap.connector.disconnect_quit(); - 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), - key_controller: gtk::EventControllerKey, - escape_future: glib::JoinHandle<()>, - disconnect_future: glib::JoinHandle<()>, -) { - let window = window.clone(); - let stop_h = stop.clone(); - let handlers = RefCell::new(Some(handlers)); - let key_controller = RefCell::new(Some(key_controller)); - 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); - } - // The key controller lives on the window (see `attach_keyboard`) — remove it so its - // chords don't keep firing app-wide against a torn-down session. - if let Some(kc) = key_controller.borrow_mut().take() { - window.remove_controller(&kc); - } - 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 index 162177de..a7d7b49c 100644 --- a/clients/linux/src/ui_trust.rs +++ b/clients/linux/src/ui_trust.rs @@ -1,76 +1,27 @@ -//! The trust gate and dialogs in front of every connect: TOFU, the SPAKE2 PIN ceremony, -//! and delegated (request-access) approval. +//! The trust dialogs in front of a connect: TOFU, the SPAKE2 PIN ceremony, and +//! delegated (request-access) approval. The trust GATE itself (rules 1–3) lives in +//! `AppModel::update` (`AppMsg::Connect`); these are the interaction surfaces it opens, +//! each resolving into typed [`AppMsg`]s. -use crate::app::App; -use crate::launch::{start_session, start_session_with, StartOpts}; +use crate::app::{AppModel, AppMsg}; +use crate::spawn::{CancelHandle, SpawnOpts}; use crate::trust; use crate::ui_hosts::ConnectRequest; use adw::prelude::*; use gtk::glib; -use std::rc::Rc; +use relm4::prelude::*; -/// 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 - } - } - } -} - -/// Wake-and-wait: an **offline** saved host with a known MAC is sent a magic packet, then we poll -/// mDNS until it comes back online — re-sending every few seconds up to a timeout — and dial it via -/// [`initiate_connect`], **re-keying the saved record if the host woke on a new DHCP IP** (matched by -/// fingerprint). A "Waking…" dialog lets the user cancel. Mirrors the Apple/Android `HostWaker` (a -/// 90 s budget, resend every 6 s). The online path stays on the fast [`initiate_connect`]; this runs -/// only from the hosts page's auto-wake when a saved host isn't advertising. -pub fn wake_and_connect(app: Rc, req: ConnectRequest) { - if app.busy.get() { - return; - } - let cancel = Rc::new(std::cell::Cell::new(false)); +/// Wake-and-wait: an **offline** saved host with a known MAC is sent a magic packet, +/// then we poll mDNS until it comes back online — re-sending every few seconds up to a +/// timeout — and route back into the trust gate, **re-keying the saved record if the +/// host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog lets the +/// user cancel. Mirrors the Apple/Android `HostWaker` (90 s budget, resend every 6 s). +pub fn wake_and_connect( + window: &adw::ApplicationWindow, + sender: &ComponentSender, + req: ConnectRequest, +) { + let cancel = std::rc::Rc::new(std::cell::Cell::new(false)); let waiting = adw::AlertDialog::new( Some("Waking Host"), Some(&format!( @@ -84,15 +35,15 @@ pub fn wake_and_connect(app: Rc, req: ConnectRequest) { let cancel = cancel.clone(); waiting.connect_response(Some("cancel"), move |_, _| cancel.set(true)); } - waiting.present(Some(&app.window)); + waiting.present(Some(window)); + let sender = sender.clone(); glib::spawn_future_local(async move { use std::time::{Duration, Instant}; let events = crate::discovery::browse(); let started = Instant::now(); let budget = Duration::from_secs(90); let resend = Duration::from_secs(6); - // Fire the first packet now, then re-send on the resend cadence. crate::wol::wake(&req.mac, req.addr.parse().ok()); let mut last_wake = Instant::now(); loop { @@ -104,7 +55,7 @@ pub fn wake_and_connect(app: Rc, req: ConnectRequest) { crate::wol::wake(&req.mac, req.addr.parse().ok()); last_wake = Instant::now(); } - // Drain resolved adverts; a match (by fingerprint, else addr:port) means the host is up. + // Drain resolved adverts; a match (fingerprint, else addr:port) = it's up. while let Ok(ev) = events.try_recv() { let crate::discovery::DiscoveryEvent::Resolved(h) = ev else { continue; @@ -116,7 +67,8 @@ pub fn wake_and_connect(app: Rc, req: ConnectRequest) { if matched { waiting.close(); let mut req = req.clone(); - // Re-key on a new DHCP lease so this + future connects dial the live address. + // Re-key on a new DHCP lease so this + future connects dial the + // live address. if h.addr != req.addr || h.port != req.port { if let Some(fp) = &req.fp_hex { trust::rekey_addr(fp, &h.addr, h.port); @@ -124,16 +76,16 @@ pub fn wake_and_connect(app: Rc, req: ConnectRequest) { req.addr = h.addr; req.port = h.port; } - initiate_connect(app.clone(), req); + sender.input(AppMsg::Connect(req)); return; } } if started.elapsed() >= budget { waiting.close(); - app.toast(&format!( + sender.input(AppMsg::Toast(format!( "Couldn't reach “{}” — is it powered and on the network?", req.name - )); + ))); return; } glib::timeout_future(Duration::from_millis(500)).await; @@ -141,9 +93,8 @@ pub fn wake_and_connect(app: Rc, req: ConnectRequest) { }); } -/// 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. +/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines, +/// 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() @@ -157,9 +108,15 @@ fn grouped_fingerprint(fp: &str) -> String { .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) { +/// First contact with a discovered host that opted into TOFU: show the advertised +/// fingerprint and let the user trust it, run the PIN ceremony instead, or walk away. +/// Trusting starts a session pinned to the advertised fp; it persists once the child +/// proves the host holds that identity (`AppMsg::SessionReady` with `tofu`). +pub fn tofu_dialog( + window: &adw::ApplicationWindow, + sender: &ComponentSender, + req: ConnectRequest, +) { let fp = req.fp_hex.clone().unwrap_or_default(); let dialog = adw::AlertDialog::new( Some("New Host"), @@ -183,29 +140,35 @@ pub fn tofu_dialog(app: Rc, req: ConnectRequest) { dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested); dialog.set_default_response(Some("trust")); dialog.set_close_response("cancel"); - let parent = app.window.clone(); + let sender = sender.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()), + "trust" => sender.input(AppMsg::StartSession { + req: req.clone(), + fp_hex: fp.clone(), + tofu: true, + opts: SpawnOpts::default(), + }), + "pair" => sender.input(AppMsg::Pair(req.clone())), _ => {} }); - dialog.present(Some(&parent)); + dialog.present(Some(window)); } /// 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) { +/// transcript. Success persists the host as paired and connects. +pub fn pin_dialog( + window: &adw::ApplicationWindow, + sender: &ComponentSender, + identity: (String, String), + 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). + // The label the HOST stores this client under — prefilled with the hostname. let name_entry = gtk::Entry::builder() .text(glib::host_name().as_str()) .activates_default(true) @@ -235,12 +198,12 @@ pub fn pin_dialog(app: Rc, req: ConnectRequest) { dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested); dialog.set_default_response(Some("pair")); dialog.set_close_response("cancel"); - let parent = app.window.clone(); + let sender = sender.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 identity = identity.clone(); + let sender = sender.clone(); let (tx, rx) = async_channel::bounded::>(1); let device = name_entry.text().trim().to_string(); let name = if device.is_empty() { @@ -257,22 +220,33 @@ pub fn pin_dialog(app: Rc, req: ConnectRequest) { 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)); + let fp_hex = trust::hex(&fp); + trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, true); + sender.input(AppMsg::Toast("Paired — connecting…".into())); + sender.input(AppMsg::StartSession { + req, + fp_hex, + tofu: false, + opts: SpawnOpts::default(), + }); } - Ok(Err(msg)) => app.toast(&msg), + Ok(Err(msg)) => sender.input(AppMsg::Toast(msg)), Err(_) => {} } }); }); - dialog.present(Some(&parent)); + dialog.present(Some(window)); } -/// 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) { +/// A fresh host that requires pairing: "Request access" (connect and wait for the +/// operator to click Approve in the host's console — delegated approval) or the PIN +/// ceremony. +pub fn approval_dialog( + window: &adw::ApplicationWindow, + sender: &ComponentSender, + waiting_slot: std::rc::Rc>>, + req: ConnectRequest, +) { let dialog = adw::AlertDialog::new( Some("Pairing Required"), Some(&format!( @@ -289,24 +263,42 @@ fn approval_dialog(app: Rc, req: ConnectRequest) { dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested); dialog.set_default_response(Some("request")); dialog.set_close_response("cancel"); - let parent = app.window.clone(); + let parent = window.clone(); + let window = window.clone(); + let sender = sender.clone(); dialog.connect_response(None, move |_, response| match response { - "request" => request_access(app.clone(), req.clone()), - "pin" => pin_dialog(app.clone(), req.clone()), + "request" => request_access(&window, &sender, waiting_slot.clone(), req.clone()), + "pin" => sender.input(AppMsg::Pair(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)); - +/// The no-PIN "request access" flow: the session child opens an identified connect the +/// host PARKS until the operator approves it in the console; a cancelable "waiting" +/// dialog covers the wait. On approval the same connection is admitted and the host is +/// saved as paired. Cancel kills the child (the only abort a parked connect has). +/// +/// The pinned fingerprint is the advertised one for a discovered host (defence against +/// an impostor while we wait). A manually-typed host has no advertised fingerprint — +/// the session binary refuses pinless connects, so this path requires the advert; a +/// manual entry's Request Access rides the same flow only when a fingerprint exists. +fn request_access( + window: &adw::ApplicationWindow, + sender: &ComponentSender, + waiting_slot: std::rc::Rc>>, + req: ConnectRequest, +) { + let Some(fp_hex) = req.fp_hex.clone() else { + // No fingerprint to pin (manual entry): the strict child can't do a + // trust-on-approval connect — route to the PIN ceremony instead. + sender.input(AppMsg::Toast( + "No advertised identity for this host — pair with a PIN instead.".into(), + )); + sender.input(AppMsg::Pair(req)); + return; + }; + let cancel = CancelHandle::default(); let waiting = adw::AlertDialog::new( Some("Waiting for Approval"), Some(&format!( @@ -319,29 +311,26 @@ fn request_access(app: Rc, req: ConnectRequest) { waiting.add_responses(&[("cancel", "Cancel")]); waiting.set_close_response("cancel"); { - let app = app.clone(); + let sender = sender.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."); + cancel.kill(); + sender.input(AppMsg::CancelPending); }); } - waiting.present(Some(&app.window)); + waiting.present(Some(window)); + *waiting_slot.borrow_mut() = Some(waiting); - start_session_with( - app, + sender.input(AppMsg::StartSession { 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), + fp_hex, + tofu: false, + opts: SpawnOpts { + // Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow + // operator approval still lands on this connection. + connect_timeout_secs: Some(185), persist_paired: true, - waiting: Some(waiting), cancel: Some(cancel), }, - ); + }); } diff --git a/clients/linux/src/video_gl.rs b/clients/linux/src/video_gl.rs deleted file mode 100644 index 876c5cf7..00000000 --- a/clients/linux/src/video_gl.rs +++ /dev/null @@ -1,664 +0,0 @@ -//! VAAPI dmabuf → RGBA GL texture converter — the Steam Deck's hardware-decode presenter. -//! -//! The direct path hands the decoder's NV12 dmabuf (fds + AMD tiled modifier) to -//! `GdkDmabufTexture` and lets GTK import + color-convert it. On the Deck that renders -//! corrupt/gray/washed-out: since Mesa 25.1 radeonsi exports VCN decode surfaces TILED, and -//! GTK's tiled-NV12 import mishandles the layout (the Flatpak runtime's Mesa drives both -//! sides). Moonlight-qt and mpv are clean on the same box because they never let a toolkit -//! near the YUV: they import the dmabuf into their own EGL context and convert with their -//! own shader. This module is that architecture for the GTK client: -//! -//! VAAPI frame → per-plane `EGLImage`s (R8 luma + GR88 chroma, modifier passed through) -//! → our YUV→RGB shader (matrix + range from the stream's real CICP signaling) -//! → an RGBA texture in a `GdkGLContext`-shared context → `GdkGLTexture` (fence-synced). -//! -//! GTK then composites a plain RGBA texture — no YUV format negotiation, no modifier -//! handling, no compositor CSC. Same-Mesa export/import is the exact proven-working path. -//! Everything runs on the GTK main thread (the converter is driven by the frame consumer); -//! one 800p–4K NV12→RGB pass is sub-millisecond GPU work. -//! -//! Failure at any step (GLX-backed GDK context, missing EGL extensions, import rejection) -//! is surfaced as an error — the caller falls back to software decode, never to the broken -//! direct path. - -use crate::video::{ColorDesc, DmabufFrame}; -use anyhow::{anyhow, bail, Context as _, Result}; -use gtk::{gdk, prelude::*}; -use khronos_egl as egl; -use std::ffi::c_void; -use std::sync::{Arc, Mutex}; - -// --- EGL_EXT_image_dma_buf_import(+_modifiers) constants (khronos-egl exposes none) ------ -const EGL_LINUX_DMA_BUF_EXT: egl::Enum = 0x3270; -// eglCreateImageKHR takes 32-bit EGLint attribs (the core-1.5 eglCreateImage variant is the -// one with pointer-sized EGLAttrib) — using the wrong width feeds the driver garbage pairs. -const EGL_LINUX_DRM_FOURCC_EXT: i32 = 0x3271; -const EGL_DMA_BUF_PLANE0_FD_EXT: i32 = 0x3272; -const EGL_DMA_BUF_PLANE0_OFFSET_EXT: i32 = 0x3273; -const EGL_DMA_BUF_PLANE0_PITCH_EXT: i32 = 0x3274; -const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: i32 = 0x3443; -const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: i32 = 0x3444; -const EGL_WIDTH: i32 = 0x3057; -const EGL_HEIGHT: i32 = 0x3056; -const EGL_NONE: i32 = 0x3038; -const DRM_FORMAT_MOD_INVALID: u64 = 0x00ff_ffff_ffff_ffff; - -/// `fourcc('N','V','1','2')` — the only decoder output today (8-bit 4:2:0). P010 joins when -/// the Linux host grows 10-bit. -const DRM_FORMAT_NV12: u32 = 0x3231_564e; -const DRM_FORMAT_R8: u32 = 0x2020_3852; -const DRM_FORMAT_GR88: u32 = 0x3838_5247; - -// --- The slice of GL we use (loaded via eglGetProcAddress — Mesa/NVIDIA both implement -// --- EGL_KHR_get_all_proc_addresses, so core functions resolve too) ---------------------- -const GL_TEXTURE_2D: u32 = 0x0DE1; -const GL_TEXTURE0: u32 = 0x84C0; -const GL_TEXTURE_MIN_FILTER: u32 = 0x2801; -const GL_TEXTURE_MAG_FILTER: u32 = 0x2800; -const GL_TEXTURE_WRAP_S: u32 = 0x2802; -const GL_TEXTURE_WRAP_T: u32 = 0x2803; -const GL_LINEAR: i32 = 0x2601; -const GL_CLAMP_TO_EDGE: i32 = 0x812F; -const GL_FRAMEBUFFER: u32 = 0x8D40; -const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0; -const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5; -const GL_RGBA8: u32 = 0x8058; -const GL_RGBA: u32 = 0x1908; -const GL_UNSIGNED_BYTE: u32 = 0x1401; -const GL_TRIANGLES: u32 = 0x0004; -const GL_VERTEX_SHADER: u32 = 0x8B31; -const GL_FRAGMENT_SHADER: u32 = 0x8B30; -const GL_COMPILE_STATUS: u32 = 0x8B81; -const GL_LINK_STATUS: u32 = 0x8B82; -const GL_SYNC_GPU_COMMANDS_COMPLETE: u32 = 0x9117; - -macro_rules! gl_fns { - ($($name:ident : fn($($arg:ty),*) $(-> $ret:ty)?;)*) => { - #[allow(non_snake_case)] - struct GlFns { $($name: unsafe extern "C" fn($($arg),*) $(-> $ret)?,)* } - impl GlFns { - #[allow(non_snake_case)] - fn load(egl: &Egl) -> Result { - $( - // eglGetProcAddress returns a plain fn pointer; the signature is fixed - // by the GL spec for each name. - let $name = egl - .get_proc_address(concat!("gl", stringify!($name))) - .ok_or_else(|| anyhow!(concat!("gl", stringify!($name), " unresolvable")))?; - )* - // SAFETY: each pointer came from eglGetProcAddress for exactly that GL entry - // point; the transmute only fixes the signature the spec defines for it. - unsafe { - Ok(GlFns { $($name: std::mem::transmute:: $ret)?>($name),)* }) - } - } - } - }; -} - -gl_fns! { - GenTextures: fn(i32, *mut u32); - DeleteTextures: fn(i32, *const u32); - BindTexture: fn(u32, u32); - TexParameteri: fn(u32, u32, i32); - TexImage2D: fn(u32, i32, i32, i32, i32, i32, u32, u32, *const c_void); - ActiveTexture: fn(u32); - EGLImageTargetTexture2DOES: fn(u32, *const c_void); - GenFramebuffers: fn(i32, *mut u32); - DeleteFramebuffers: fn(i32, *const u32); - BindFramebuffer: fn(u32, u32); - FramebufferTexture2D: fn(u32, u32, u32, u32, i32); - CheckFramebufferStatus: fn(u32) -> u32; - Viewport: fn(i32, i32, i32, i32); - CreateShader: fn(u32) -> u32; - ShaderSource: fn(u32, i32, *const *const u8, *const i32); - CompileShader: fn(u32); - GetShaderiv: fn(u32, u32, *mut i32); - GetShaderInfoLog: fn(u32, i32, *mut i32, *mut u8); - DeleteShader: fn(u32); - CreateProgram: fn() -> u32; - AttachShader: fn(u32, u32); - LinkProgram: fn(u32); - GetProgramiv: fn(u32, u32, *mut i32); - UseProgram: fn(u32); - GetUniformLocation: fn(u32, *const u8) -> i32; - Uniform1i: fn(i32, i32); - Uniform3fv: fn(i32, i32, *const f32); - UniformMatrix3fv: fn(i32, i32, u8, *const f32); - GenVertexArrays: fn(i32, *mut u32); - DeleteVertexArrays: fn(i32, *const u32); - DeleteProgram: fn(u32); - BindVertexArray: fn(u32); - DrawArrays: fn(u32, i32, i32); - FenceSync: fn(u32, u32) -> *const c_void; - DeleteSync: fn(*const c_void); - Flush: fn(); - GetError: fn() -> u32; -} - -type Egl = egl::DynamicInstance; -type EglCreateImageKhr = unsafe extern "C" fn( - *mut c_void, // EGLDisplay - *mut c_void, // EGLContext (EGL_NO_CONTEXT for dmabuf) - egl::Enum, - *mut c_void, // EGLClientBuffer (null for dmabuf) - *const i32, // EGLint attrib list (KHR variant — NOT pointer-sized EGLAttrib) -) -> *const c_void; -type EglDestroyImageKhr = unsafe extern "C" fn(*mut c_void, *const c_void) -> egl::Boolean; - -/// The YUV→RGB conversion for a stream's CICP signaling: `rgb = mat * (yuv + off)`, with the -/// limited/full-range expansion folded in. `mat` is column-major (GL convention). Pure — -/// unit-tested against the reference white/black points. -pub fn yuv_to_rgb(desc: ColorDesc) -> ([f32; 9], [f32; 3]) { - // BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's - // BT.709 SDR default (mirrors the software path's swscale coefficient choice). - let (kr, kb) = match desc.matrix { - 5 | 6 => (0.299, 0.114), - 9 | 10 => (0.2627, 0.0593), - _ => (0.2126, 0.0722), - }; - let kg = 1.0 - kr - kb; - let (sy, oy, sc) = if desc.full_range { - (1.0f32, 0.0f32, 1.0f32) - } else { - (255.0 / 219.0, -16.0 / 255.0, 255.0 / 224.0) - }; - let (kr, kb, kg) = (kr as f32, kb as f32, kg as f32); - // Column-major: columns are the Y, U, V contributions to (R, G, B). - let mat = [ - sy, - sy, - sy, // Y column - 0.0, - -2.0 * (1.0 - kb) * kb / kg * sc, - 2.0 * (1.0 - kb) * sc, // U column - 2.0 * (1.0 - kr) * sc, - -2.0 * (1.0 - kr) * kr / kg * sc, - 0.0, // V column - ]; - (mat, [oy, -0.5, -0.5]) -} - -/// An output texture GTK has released, waiting to be recycled (or its fence deleted). GL -/// objects can only be touched with our context current, so releases park here and -/// [`GlConverter::convert`] drains them. -struct Retired { - tex: u32, - sync: usize, // GLsync as usize — the release closure must be Send - size: (u32, u32), -} - -pub struct GlConverter { - ctx: gdk::GLContext, - egl: Egl, - egl_display: *mut c_void, - create_image: EglCreateImageKhr, - destroy_image: EglDestroyImageKhr, - gl: GlFns, - program: u32, - vao: u32, - fbo: u32, - u_mat: i32, - u_off: i32, - /// Uniforms match this signaling; a change (mid-stream SDR↔HDR) re-uploads them. - uniforms_for: Option, - /// Free output textures + fences returned by GTK's release funcs (shared with the - /// `Send` release closures; drained/recycled at each convert). - retired: Arc>>, -} - -impl GlConverter { - /// Build against the widget's display. Must run on the GTK main thread; fails cleanly - /// on a GLX-backed GDK context or missing EGL dmabuf-import extensions (the caller - /// falls back to software decode). - pub fn new(widget: &impl IsA) -> Result { - let display = widget.display(); - let ctx = display.create_gl_context().context("create GdkGLContext")?; - ctx.realize().context("realize GdkGLContext")?; - ctx.make_current(); - - // SAFETY (whole block): the GdkGLContext is current on this thread, so EGL/GL - // queries and object creation target it; pointers are only used while it lives. - unsafe { - let egl = Egl::load_required().context("dlopen libEGL")?; - let egl_display = egl - .get_current_display() - .ok_or_else(|| anyhow!("GDK context is not EGL-backed (GLX?)"))?; - let exts = egl - .query_string(Some(egl_display), egl::EXTENSIONS) - .context("EGL_EXTENSIONS")? - .to_string_lossy() - .into_owned(); - for need in ["EGL_EXT_image_dma_buf_import", "EGL_KHR_image_base"] { - if !exts.contains(need) { - bail!("EGL lacks {need}"); - } - } - // Tiled surfaces carry an explicit modifier — without the _modifiers extension - // the import would silently assume implied/linear and sample garbage. - if !exts.contains("EGL_EXT_image_dma_buf_import_modifiers") { - bail!("EGL lacks EGL_EXT_image_dma_buf_import_modifiers"); - } - let create_image: EglCreateImageKhr = - std::mem::transmute::( - egl.get_proc_address("eglCreateImageKHR") - .ok_or_else(|| anyhow!("no eglCreateImageKHR"))?, - ); - let destroy_image: EglDestroyImageKhr = - std::mem::transmute::( - egl.get_proc_address("eglDestroyImageKHR") - .ok_or_else(|| anyhow!("no eglDestroyImageKHR"))?, - ); - let gl = GlFns::load(&egl)?; - - let es = ctx.api().contains(gdk::GLAPI::GLES); - let program = build_program(&gl, es)?; - (gl.UseProgram)(program); - let u_mat = (gl.GetUniformLocation)(program, c"u_mat".as_ptr() as *const u8); - let u_off = (gl.GetUniformLocation)(program, c"u_off".as_ptr() as *const u8); - let u_y = (gl.GetUniformLocation)(program, c"u_y".as_ptr() as *const u8); - let u_c = (gl.GetUniformLocation)(program, c"u_c".as_ptr() as *const u8); - (gl.Uniform1i)(u_y, 0); - (gl.Uniform1i)(u_c, 1); - let mut vao = 0u32; - (gl.GenVertexArrays)(1, &mut vao); - let mut fbo = 0u32; - (gl.GenFramebuffers)(1, &mut fbo); - - tracing::info!( - gles = es, - "GL presenter ready — VAAPI dmabufs convert in-process (own EGL import + shader)" - ); - Ok(GlConverter { - ctx, - egl, - egl_display: egl_display.as_ptr(), - create_image, - destroy_image, - gl, - program, - vao, - fbo, - u_mat, - u_off, - uniforms_for: None, - retired: Arc::new(Mutex::new(Vec::new())), - }) - } - } - - /// Convert one decoded frame into an RGBA `GdkTexture`. The source surface (guard) is - /// held until GTK releases the output texture — the GPU read is long finished by then. - /// `color_state` tags the output (full-range RGB, transfer left baked — same semantics - /// as the software path's tagged `GdkMemoryTexture`); `None` = untagged sRGB. - pub fn convert( - &mut self, - frame: DmabufFrame, - color_state: Option<&gdk::ColorState>, - ) -> Result { - if frame.fourcc != DRM_FORMAT_NV12 { - bail!("GL presenter handles NV12 only (got {:#x})", frame.fourcc); - } - if frame.planes.len() < 2 { - bail!("NV12 needs 2 planes (got {})", frame.planes.len()); - } - self.ctx.make_current(); - let gl = &self.gl; - - // SAFETY (whole body): our context is current; every GL/EGL object created here is - // either destroyed before return or owned by the pool/release machinery. - unsafe { - // Recycle what GTK released since last frame (GL objects need the context, so - // the release closures only park entries — this is where they die/revive). - let size = (frame.width, frame.height); - let mut out_tex = 0u32; - { - let mut retired = self.retired.lock().unwrap(); - retired.retain_mut(|r| { - if r.sync != 0 { - (gl.DeleteSync)(r.sync as *const c_void); - r.sync = 0; - } - if out_tex == 0 && r.size == size { - out_tex = r.tex; - false - } else if r.size != size { - (gl.DeleteTextures)(1, &r.tex); // stale size (mode change) - false - } else { - true // spare same-size texture for a later frame - } - }); - } - if out_tex == 0 { - (gl.GenTextures)(1, &mut out_tex); - (gl.BindTexture)(GL_TEXTURE_2D, out_tex); - (gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - (gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - (gl.TexImage2D)( - GL_TEXTURE_2D, - 0, - GL_RGBA8 as i32, - frame.width as i32, - frame.height as i32, - 0, - GL_RGBA, - GL_UNSIGNED_BYTE, - std::ptr::null(), - ); - } - - // Import both planes with the surface's modifier — exactly the layer-wise - // import Moonlight/mpv drive on this hardware. - let y = &frame.planes[0]; - let c = &frame.planes[1]; - let img_y = - self.plane_image(frame.width, frame.height, DRM_FORMAT_R8, y, frame.modifier)?; - let img_c = match self.plane_image( - frame.width.div_ceil(2), - frame.height.div_ceil(2), - DRM_FORMAT_GR88, - c, - frame.modifier, - ) { - Ok(img) => img, - Err(e) => { - (self.destroy_image)(self.egl_display, img_y); - return Err(e); - } - }; - - let mut planes = [0u32; 2]; - (gl.GenTextures)(2, planes.as_mut_ptr()); - for (tex, img) in planes.iter().zip([img_y, img_c]) { - (gl.BindTexture)(GL_TEXTURE_2D, *tex); - (gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - (gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - (gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - (gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - (gl.EGLImageTargetTexture2DOES)(GL_TEXTURE_2D, img); - } - - (gl.UseProgram)(self.program); - if self.uniforms_for != Some(frame.color) { - let (mat, off) = yuv_to_rgb(frame.color); - (gl.UniformMatrix3fv)(self.u_mat, 1, 0, mat.as_ptr()); - (gl.Uniform3fv)(self.u_off, 1, off.as_ptr()); - self.uniforms_for = Some(frame.color); - } - (gl.BindFramebuffer)(GL_FRAMEBUFFER, self.fbo); - (gl.FramebufferTexture2D)( - GL_FRAMEBUFFER, - GL_COLOR_ATTACHMENT0, - GL_TEXTURE_2D, - out_tex, - 0, - ); - let status = (gl.CheckFramebufferStatus)(GL_FRAMEBUFFER); - if status != GL_FRAMEBUFFER_COMPLETE { - (gl.BindFramebuffer)(GL_FRAMEBUFFER, 0); - (gl.DeleteTextures)(2, planes.as_ptr()); - (self.destroy_image)(self.egl_display, img_y); - (self.destroy_image)(self.egl_display, img_c); - (gl.DeleteTextures)(1, &out_tex); - bail!("FBO incomplete ({status:#x})"); - } - (gl.Viewport)(0, 0, frame.width as i32, frame.height as i32); - (gl.BindVertexArray)(self.vao); - (gl.ActiveTexture)(GL_TEXTURE0); - (gl.BindTexture)(GL_TEXTURE_2D, planes[0]); - (gl.ActiveTexture)(GL_TEXTURE0 + 1); - (gl.BindTexture)(GL_TEXTURE_2D, planes[1]); - (gl.DrawArrays)(GL_TRIANGLES, 0, 3); - (gl.BindFramebuffer)(GL_FRAMEBUFFER, 0); - - let sync = (gl.FenceSync)(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); - (gl.Flush)(); - // The draw is queued: plane textures + images can go now (the driver keeps the - // underlying buffers alive until the queued commands execute). - (gl.DeleteTextures)(2, planes.as_ptr()); - (self.destroy_image)(self.egl_display, img_y); - (self.destroy_image)(self.egl_display, img_c); - - let err = (gl.GetError)(); - if err != 0 { - (gl.DeleteTextures)(1, &out_tex); - bail!("GL error {err:#x} during convert"); - } - - let mut b = gdk::GLTextureBuilder::new() - .set_context(Some(&self.ctx)) - .set_id(out_tex) - .set_width(frame.width as i32) - .set_height(frame.height as i32) - .set_format(gdk::MemoryFormat::R8g8b8a8) - .set_sync(Some(sync)); - if let Some(state) = color_state { - b = b.set_color_state(state); - } - let retired = self.retired.clone(); - let guard = frame.guard; - let sync_bits = sync as usize; // GLsync as usize — the closure must be Send - let texture = b.build_with_release_func(move || { - drop(guard); // the decoder surface outlived every GPU read of it - retired.lock().unwrap().push(Retired { - tex: out_tex, - sync: sync_bits, - size, - }); - }); - Ok(texture) - } - } - - /// One single-plane `EGLImage` over a dmabuf plane (R8 luma / GR88 chroma), modifier - /// passed explicitly. - /// - /// # Safety - /// `self.ctx` must be current; the fd stays owned by the caller (EGL dups internally). - unsafe fn plane_image( - &self, - width: u32, - height: u32, - fourcc: u32, - plane: &crate::video::DmabufPlane, - modifier: u64, - ) -> Result<*const c_void> { - let mut attribs = vec![ - EGL_WIDTH, - width as i32, - EGL_HEIGHT, - height as i32, - EGL_LINUX_DRM_FOURCC_EXT, - fourcc as i32, - EGL_DMA_BUF_PLANE0_FD_EXT, - plane.fd, - EGL_DMA_BUF_PLANE0_OFFSET_EXT, - plane.offset as i32, - EGL_DMA_BUF_PLANE0_PITCH_EXT, - plane.stride as i32, - ]; - if modifier != DRM_FORMAT_MOD_INVALID && modifier != 0 { - attribs.extend_from_slice(&[ - EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT, - (modifier & 0xffff_ffff) as u32 as i32, - EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT, - (modifier >> 32) as u32 as i32, - ]); - } - attribs.push(EGL_NONE); - // SAFETY: attribs is a valid EGL_NONE-terminated list; display/context are live. - let img = unsafe { - (self.create_image)( - self.egl_display, - std::ptr::null_mut(), // EGL_NO_CONTEXT — dmabuf import - EGL_LINUX_DMA_BUF_EXT, - std::ptr::null_mut(), - attribs.as_ptr(), - ) - }; - if img.is_null() { - bail!( - "eglCreateImageKHR rejected plane ({}x{} {:#x} mod {:#018x}): {:?}", - width, - height, - fourcc, - modifier, - self.egl.get_error() - ); - } - Ok(img) - } -} - -impl Drop for GlConverter { - /// Delete our objects from the shared context group (the context lives in GDK's share - /// group — per-session leftovers would pile up across sessions). Textures GTK still - /// holds at this moment release into `retired` afterwards, where nobody drains them: - /// those names leak, but it's ≤ the pool depth once per session, not per frame. - fn drop(&mut self) { - self.ctx.make_current(); - let gl = &self.gl; - // SAFETY: context current; only objects this converter created are deleted. - unsafe { - for r in self.retired.lock().unwrap().drain(..) { - if r.sync != 0 { - (gl.DeleteSync)(r.sync as *const c_void); - } - (gl.DeleteTextures)(1, &r.tex); - } - (gl.DeleteFramebuffers)(1, &self.fbo); - (gl.DeleteVertexArrays)(1, &self.vao); - (gl.DeleteProgram)(self.program); - } - } -} - -/// Compile the fullscreen-triangle NV12→RGB program (GLSL 300 es / 330 core per the GDK -/// context's API). `gl_VertexID` drives the geometry — no buffers at all. -/// -/// # Safety -/// A GL context must be current; `gl` must belong to it. -unsafe fn build_program(gl: &GlFns, es: bool) -> Result { - let header = if es { - "#version 300 es\nprecision highp float;\n" - } else { - "#version 330 core\n" - }; - let vs_src = format!( - "{header} -out vec2 v_uv; -void main() {{ - vec2 p = vec2(float((gl_VertexID & 1) << 2) - 1.0, float((gl_VertexID & 2) << 1) - 1.0); - v_uv = p * 0.5 + 0.5; - gl_Position = vec4(p, 0.0, 1.0); -}}" - ); - let fs_src = format!( - "{header} -in vec2 v_uv; -out vec4 frag; -uniform sampler2D u_y; -uniform sampler2D u_c; -uniform mat3 u_mat; -uniform vec3 u_off; -void main() {{ - vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, v_uv).rg); - frag = vec4(clamp(u_mat * (yuv + u_off), 0.0, 1.0), 1.0); -}}" - ); - // SAFETY: caller holds a current context; sources are valid UTF-8 with explicit lengths. - unsafe { - let compile = |kind: u32, src: &str| -> Result { - let sh = (gl.CreateShader)(kind); - let ptr = src.as_ptr(); - let len = src.len() as i32; - (gl.ShaderSource)(sh, 1, &ptr, &len); - (gl.CompileShader)(sh); - let mut ok = 0i32; - (gl.GetShaderiv)(sh, GL_COMPILE_STATUS, &mut ok); - if ok == 0 { - let mut log = vec![0u8; 1024]; - let mut n = 0i32; - (gl.GetShaderInfoLog)(sh, 1024, &mut n, log.as_mut_ptr()); - (gl.DeleteShader)(sh); - bail!( - "shader compile: {}", - String::from_utf8_lossy(&log[..n.max(0) as usize]) - ); - } - Ok(sh) - }; - let vs = compile(GL_VERTEX_SHADER, &vs_src)?; - let fs = match compile(GL_FRAGMENT_SHADER, &fs_src) { - Ok(fs) => fs, - Err(e) => { - (gl.DeleteShader)(vs); - return Err(e); - } - }; - let prog = (gl.CreateProgram)(); - (gl.AttachShader)(prog, vs); - (gl.AttachShader)(prog, fs); - (gl.LinkProgram)(prog); - (gl.DeleteShader)(vs); - (gl.DeleteShader)(fs); - let mut ok = 0i32; - (gl.GetProgramiv)(prog, GL_LINK_STATUS, &mut ok); - if ok == 0 { - bail!("program link failed"); - } - Ok(prog) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn desc(matrix: u8, full_range: bool) -> ColorDesc { - ColorDesc { - primaries: 1, - transfer: 1, - matrix, - full_range, - } - } - - fn apply(mat: &[f32; 9], off: &[f32; 3], yuv: [f32; 3]) -> [f32; 3] { - let v = [yuv[0] + off[0], yuv[1] + off[1], yuv[2] + off[2]]; - // Column-major: out[r] = Σ mat[col*3 + r] * v[col] - core::array::from_fn(|r| (0..3).map(|c| mat[c * 3 + r] * v[c]).sum()) - } - - /// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0. - #[test] - fn bt709_limited_white_black() { - let (mat, off) = yuv_to_rgb(desc(1, false)); - let white = apply(&mat, &off, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]); - let black = apply(&mat, &off, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]); - for (w, b) in white.iter().zip(black) { - assert!((w - 1.0).abs() < 0.005, "white {white:?}"); - assert!(b.abs() < 0.005, "black {black:?}"); - } - } - - /// Full-range identity points: Y=1 → white, Y=0 → black, and a 601-vs-709 red spot - /// check (pure V excursion produces R = 2(1−Kr)·0.5). - #[test] - fn full_range_and_red_excursion() { - let (mat, off) = yuv_to_rgb(desc(5, true)); - let white = apply(&mat, &off, [1.0, 0.5, 0.5]); - assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}"); - let red = apply(&mat, &off, [0.0, 0.5, 1.0]); - assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}"); - // 709 differs from 601 in the same spot — guards the matrix-code dispatch. - let (mat709, off709) = yuv_to_rgb(desc(1, true)); - let red709 = apply(&mat709, &off709, [0.0, 0.5, 1.0]); - assert!( - (red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4, - "{red709:?}" - ); - assert!((red[0] - red709[0]).abs() > 0.05); - } -} diff --git a/clients/linux/tools/screenshots.sh b/clients/linux/tools/screenshots.sh index 8ca6e249..6979e75c 100755 --- a/clients/linux/tools/screenshots.sh +++ b/clients/linux/tools/screenshots.sh @@ -27,7 +27,7 @@ GEOMETRY="${GEOMETRY:-1380x860x24}" SETTLE="${SETTLE:-1.2}" SHOT_DISPLAY="${SHOT_DISPLAY:-:99}" -if [ "$#" -gt 0 ]; then SCENES=("$@"); else SCENES=(hosts settings trust pair addhost shortcuts library gamepad-library); 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