//! Session launch: resolve the stream mode, spawn the session worker, and drive its //! event stream into the UI (trust persistence, stream-page push, teardown). use crate::app::App; use crate::session::{SessionEvent, SessionParams, Stats}; use crate::trust; use crate::ui_hosts::ConnectRequest; use crate::video::DecodedFrame; use adw::prelude::*; use gtk::{gdk, glib}; use punktfunk_core::client::NativeClient; use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; use std::rc::Rc; use std::sync::atomic::AtomicBool; use std::sync::Arc; /// The mode to request: explicit settings, with `0` fields resolved to the native /// size/refresh of the monitor the window currently occupies (mirrors the Swift client's /// native-display default). fn resolve_mode(app: &App) -> Mode { let s = app.settings.borrow(); let mut mode = Mode { width: s.width, height: s.height, refresh_hz: s.refresh_hz, }; if mode.width == 0 || mode.refresh_hz == 0 { // Prefer the monitor the window is on; fall back to the display's first monitor. On a // `--connect` launch the window may not be mapped yet when this runs, and without the // fallback we'd drop to the 1920×1080 floor below — wrong on the Deck (1280×800). let monitor = app .window .surface() .zip(gdk::Display::default()) .and_then(|(surf, d)| d.monitor_at_surface(&surf)) .or_else(|| { gdk::Display::default() .and_then(|d| d.monitors().item(0)) .and_then(|o| o.downcast::().ok()) }); if let Some(m) = monitor { let geo = m.geometry(); let scale = m.scale_factor().max(1); if mode.width == 0 { mode.width = (geo.width() * scale) as u32; mode.height = (geo.height() * scale) as u32; } if mode.refresh_hz == 0 { mode.refresh_hz = ((m.refresh_rate() + 500) / 1000).max(30) as u32; } } } // No monitor info (early call, odd compositor) — a sane floor. if mode.width == 0 { (mode.width, mode.height) = (1920, 1080); } if mode.refresh_hz == 0 { mode.refresh_hz = 60; } mode } /// Tunables for a session start that differ between the normal connect and the "request access" /// (delegated-approval) flow. `Default` is the normal connect. pub struct StartOpts { /// Handshake budget. The request-access flow uses a long one because the host PARKS the /// connection until the operator clicks Approve (see the host's `PENDING_APPROVAL_WAIT`). pub connect_timeout: std::time::Duration, /// Persist the host as *paired* on a successful connect. Set for request-access, where the /// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU /// persists the host *unpaired* (pinned, but not PIN/approval-verified). pub persist_paired: bool, /// A "waiting for approval" dialog to dismiss on the first session event (request-access only). pub waiting: Option, /// Set by the waiting dialog's Cancel button. `NativeClient::connect` is a blocking call with /// no abort, so Cancel returns the UI immediately (clears busy, closes the dialog) and leaves /// the in-flight connect to time out; when it finally resolves, the event loop sees this flag /// and tears down silently (drops the connector → closes the connection) without touching the /// UI a new session may already own. pub cancel: Option>>, } impl Default for StartOpts { fn default() -> Self { Self { connect_timeout: std::time::Duration::from_secs(15), persist_paired: false, waiting: None, cancel: None, } } } pub fn start_session(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>) { start_session_with(app, req, pin, StartOpts::default()); } pub fn start_session_with( app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>, opts: StartOpts, ) { if app.busy.replace(true) { return; } let mode = resolve_mode(&app); let s = app.settings.borrow(); let params = SessionParams { host: req.addr.clone(), port: req.port, mode, compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto), // "Automatic" matches the physical pad (Swift parity); an explicit choice wins. gamepad: match GamepadPref::from_name(&s.gamepad) { Some(GamepadPref::Auto) | None => app.gamepad.auto_pref(), Some(explicit) => explicit, }, bitrate_kbps: s.bitrate_kbps, mic_enabled: s.mic_enabled, audio_channels: s.audio_channels, preferred_codec: s.preferred_codec(), decoder: s.decoder.clone(), launch: req.launch.as_ref().map(|(id, _)| id.clone()), pin, identity: app.identity.clone(), connect_timeout: opts.connect_timeout, }; let inhibit = s.inhibit_shortcuts; let show_stats = s.show_stats; drop(s); let cancel = opts.cancel; // Card feedback while the connect is in flight: spinner on the matching hosts card, // stale failure banner dismissed. Cleared again on Connected/Failed/Ended. if let Some(h) = app.hosts_ui() { h.clear_error(); h.set_connecting(Some(req.card_key())); } let mut handle = crate::session::start(params); let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1); let mut ctx = SessionUi { stop: handle.stop.clone(), app, req, persist_paired: opts.persist_paired, tofu: pin.is_none(), inhibit, show_stats, frames: Some(frames), waiting: opts.waiting, page: None, }; glib::spawn_future_local(async move { while let Ok(event) = handle.events.recv().await { // A cancelled request-access connect resolved late: tear down silently. Don't touch // app.busy — Cancel already cleared it, and a fresh session may now own it. if cancel.as_ref().is_some_and(|c| c.get()) { ctx.close_waiting(); break; } match event { SessionEvent::Connected { connector, mode, fingerprint, } => ctx.on_connected(connector, mode, fingerprint), SessionEvent::Stats(s) => ctx.on_stats(s), SessionEvent::Failed { msg, trust_rejected, } => { ctx.on_failed(&msg, trust_rejected); break; } SessionEvent::Ended(err) => { ctx.on_ended(err); break; } } } }); } /// UI-side state one session's event loop carries between events. struct SessionUi { app: Rc, req: ConnectRequest, /// Persist the host as PAIRED on `Connected` (request-access — the approval IS the pairing). persist_paired: bool, /// This is a TOFU connect (no stored pin): pin the observed fingerprint on `Connected`. tofu: bool, /// Grab compositor shortcuts while input is captured (Settings). inhibit: bool, /// Show the stats OSD when the stream page opens (Settings; live-toggled on-page). show_stats: bool, stop: Arc, /// Decoded-frame receiver, handed to the stream page once on `Connected`. frames: Option>, /// The "waiting for approval" dialog (request-access flow), dismissed on the first event. waiting: Option, page: Option, } impl SessionUi { /// Dismiss the "waiting for approval" dialog (request-access flow), if any. fn close_waiting(&mut self) { if let Some(w) = self.waiting.take() { w.close(); } } /// `Connected`: record the configured trust decision, attach gamepads, and push the /// stream page. fn on_connected(&mut self, connector: Arc, mode: Mode, fingerprint: [u8; 32]) { self.close_waiting(); if let Some(h) = self.app.hosts_ui() { h.set_connecting(None); } if self.persist_paired { // Request-access: the operator approved this device, so record the host as // a trusted PAIRED host (pinning the fingerprint we observed) — future // connects are then silent (rule 1), exactly like after a PIN ceremony. let fp_hex = trust::hex(&fingerprint); trust::persist_host(&self.req.name, &self.req.addr, self.req.port, &fp_hex, true); self.app.toast("Approved — connecting…"); } else if self.tofu { // A TOFU connect just observed the real fingerprint — pin it from now on. let fp_hex = trust::hex(&fingerprint); trust::persist_host( &self.req.name, &self.req.addr, self.req.port, &fp_hex, false, ); self.app.toast(&format!( "Trusted on first use — fingerprint {}…", &fp_hex[..16] )); } // Stamp the successful connect — this host's card carries the accent bar now. trust::touch_last_used(&trust::hex(&fingerprint)); tracing::debug!(?mode, "connected — pushing stream page"); // A library launch titles the stream with the game, not the host. let name = self .req .launch .as_ref() .map_or(self.req.name.as_str(), |(_, game)| game.as_str()); let title = format!( "{name} · {}×{}@{}", mode.width, mode.height, mode.refresh_hz ); self.app.gamepad.attach(connector.clone()); let clock_offset_ns = connector.clock_offset_ns; let p = crate::ui_stream::new(crate::ui_stream::StreamPageArgs { window: self.app.window.clone(), connector, frames: self.frames.take().expect("Connected delivered once"), clock_offset_ns, escape_rx: self.app.gamepad.escape_events(), disconnect_rx: self.app.gamepad.disconnect_events(), stop: self.stop.clone(), inhibit_shortcuts: self.inhibit, show_stats: self.show_stats, chromeless: self.app.fullscreen, 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(); } self.page = Some(p); } fn on_stats(&self, s: Stats) { if let Some(p) = &self.page { p.update_stats(s); } } /// `Failed`: surface the error; a trust rejection on a pinned connect routes to re-pairing. fn on_failed(&mut self, msg: &str, trust_rejected: bool) { self.close_waiting(); tracing::warn!(%msg, trust_rejected, "connect failed"); self.app.busy.set(false); if let Some(h) = self.app.hosts_ui() { h.set_connecting(None); } // A pinned connect rejected on trust grounds means the host's cert no // longer matches the stored pin (rotated cert or impostor) — route to // the PIN ceremony to re-establish trust rather than dead-ending. if trust_rejected && !self.tofu { self.app .toast("Host fingerprint changed — re-pair with a PIN to continue"); crate::ui_trust::pin_dialog(self.app.clone(), self.req.clone()); } else { // Errors land on the hosts page banner, not a transient toast. self.app.connect_error(&format!("Couldn't connect — {msg}")); } } /// `Ended`: detach gamepads, pop back to the hosts page, and surface the reason. fn on_ended(&mut self, err: Option) { self.close_waiting(); self.app.gamepad.detach(); self.app.nav.pop_to_tag("hosts"); if let Some(h) = self.app.hosts_ui() { h.set_connecting(None); } if let Some(e) = err { self.app.connect_error(&e); } self.app.busy.set(false); } }