//! The application shell: window, navigation, trust dialogs, session lifecycle. use crate::session::{SessionEvent, SessionParams}; use crate::trust::{KnownHost, KnownHosts, Settings}; use crate::ui_hosts::ConnectRequest; use adw::prelude::*; use gtk::{gdk, glib}; use punktfunk_core::client::NativeClient; use punktfunk_core::config::{CompositorPref, GamepadPref}; use std::cell::RefCell; use std::rc::Rc; const APP_ID: &str = "io.unom.Punktfunk"; struct App { window: adw::ApplicationWindow, nav: adw::NavigationView, toasts: adw::ToastOverlay, settings: Rc>, identity: (String, String), /// App-lifetime SDL gamepad service: Settings list + per-session capture/feedback. gamepad: crate::gamepad::GamepadService, /// One session at a time — ignore connects while one is starting/running. busy: std::cell::Cell, /// Steam Deck / Gaming-Mode launch: fullscreen the window (chrome-less) when a stream starts. fullscreen: bool, } impl App { fn toast(&self, msg: &str) { self.toasts.add_toast(adw::Toast::new(msg)); } } pub fn run() -> glib::ExitCode { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), ) .init(); // Headless pairing path (no GTK window): `--pair --connect host[:port] [--name N]`. // Used by the Decky plugin (a GTK dialog can't pop under gamescope) and for scripting. if let Some(pin) = arg_value("--pair") { return headless_pair(&pin); } let mut builder = adw::Application::builder().application_id(APP_ID); // Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps each // launch its own primary instance instead of forwarding to a still-registered name. if shot_scene().is_some() { builder = builder.flags(gtk::gio::ApplicationFlags::NON_UNIQUE); } let app = builder.build(); app.connect_activate(build_ui); // 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]) } /// The value following `flag` in argv, if present (`--flag value`). fn arg_value(flag: &str) -> Option { std::env::args() .skip_while(|a| a != flag) .nth(1) .filter(|v| !v.starts_with("--")) } /// True if argv contains `flag` (a valueless switch). fn arg_flag(flag: &str) -> bool { std::env::args().any(|a| a == flag) } /// Run the stream fullscreen with no window chrome — the Steam Deck / Gaming-Mode launch path. /// The Decky wrapper passes `--fullscreen`; we also honor the Deck/gamescope env as a fallback /// so a manual launch under Gaming Mode does the right thing too. fn fullscreen_mode() -> bool { arg_flag("--fullscreen") || std::env::var_os("SteamDeck").is_some() || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() } /// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the /// known-hosts store as paired, so a later `--connect` connects silently. Same identity /// store the streaming path uses (same binary), so pairing here makes the stream work. /// Prints a one-line `paired : fp=` on success; exits non-zero on failure. fn headless_pair(pin: &str) -> glib::ExitCode { let Some(target) = arg_value("--connect") else { eprintln!("--pair requires --connect host[:port]"); return glib::ExitCode::FAILURE; }; let (addr, port) = match target.rsplit_once(':') { Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)), None => (target.clone(), 9777), }; // The label the HOST stores this client under (its paired-devices list). let name = arg_value("--name").unwrap_or_else(|| "Steam Deck".to_string()); let identity = match crate::trust::load_or_create_identity() { Ok(i) => i, Err(e) => { eprintln!("client identity: {e:#}"); return glib::ExitCode::FAILURE; } }; match NativeClient::pair( &addr, port, (&identity.0, &identity.1), pin.trim(), &name, std::time::Duration::from_secs(90), ) { Ok(fp) => { let fp_hex = crate::trust::hex(&fp); let mut known = KnownHosts::load(); known.upsert(KnownHost { name: arg_value("--host-label").unwrap_or_else(|| addr.clone()), addr: addr.clone(), port, fp_hex: fp_hex.clone(), paired: true, }); let _ = known.save(); println!("paired {addr}:{port} fp={fp_hex}"); glib::ExitCode::SUCCESS } Err(e) => { eprintln!("pairing failed: {e:?} (wrong PIN, or pairing not armed on the host?)"); glib::ExitCode::FAILURE } } } /// `--connect host[:port]` — skip the hosts page and start a session immediately /// (scripting + headless testing). Trust follows the same rules as a manual entry: a host /// already pinned at this address connects silently on its stored pin; an unknown host is /// routed to the PIN ceremony (never a silent TOFU connect — `fp_hex`/`pair_optional` are /// unset, so `initiate_connect`'s manual arm mandates pairing). fn cli_connect_request() -> Option { let args: Vec = std::env::args().collect(); let target = args .iter() .skip_while(|a| *a != "--connect") .nth(1)? .clone(); let (addr, port) = match target.rsplit_once(':') { Some((a, p)) => (a.to_string(), p.parse().ok()?), None => (target.clone(), 9777), }; Some(ConnectRequest { name: addr.clone(), addr, port, fp_hex: None, pair_optional: false, }) } fn build_ui(gtk_app: &adw::Application) { let identity = match crate::trust::load_or_create_identity() { Ok(i) => i, Err(e) => { tracing::error!("client identity: {e:#}"); std::process::exit(1); } }; 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(1100) .default_height(720) .content(&toasts) .build(); let app = Rc::new(App { window: window.clone(), nav: nav.clone(), toasts, settings: Rc::new(RefCell::new(Settings::load())), identity, gamepad: crate::gamepad::GamepadService::start(), busy: std::cell::Cell::new(false), fullscreen: fullscreen_mode(), }); let hosts_page = crate::ui_hosts::new( { let app = app.clone(); Rc::new(move |req| initiate_connect(app.clone(), req)) }, { let app = app.clone(); Rc::new(move || { crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad) }) }, { let app = app.clone(); Rc::new(move |req| speed_test(app.clone(), req)) }, ); nav.add(&hosts_page); window.present(); // CI screenshot mode: render one scripted, host-free scene and signal readiness // (clients/linux/tools/screenshots.sh). Mutually exclusive with a real connect. if let Some(scene) = shot_scene() { run_shot(app, &scene); return; } if let Some(req) = cli_connect_request() { initiate_connect(app, req); } } /// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots. fn shot_scene() -> Option { std::env::var("PUNKTFUNK_SHOT_SCENE") .ok() .filter(|s| !s.is_empty()) } /// Render one mock-populated, host-free scene over the already-presented window, then print /// `PF_SHOT_READY` once it has had a moment to map + settle so the driver knows when to capture. /// No `NativeClient` or session is created. The stream scene is deliberately absent — its page /// requires a live connector (`ui_stream::new` takes an `Arc`). fn run_shot(app: Rc, scene: &str) { // A plausible host for the trust/pair dialogs (fp_hex is 64 hex chars, like a real SHA-256). let mock_req = || ConnectRequest { name: "Living Room PC".to_string(), addr: "192.168.1.42".to_string(), port: 9777, fp_hex: Some( "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00".to_string(), ), pair_optional: true, }; match scene { // The saved-hosts grid reads ~/.config/punktfunk/client-known-hosts.json, which the // driver seeds — so the already-shown hosts page is the scene; nothing to do here. "hosts" | "02-hosts" => {} "settings" | "03-settings" => { crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad); } "trust" | "04-trust" => tofu_dialog(app.clone(), mock_req()), "pair" | "05-pair" => pin_dialog(app.clone(), mock_req()), other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"), } let settle_ms = std::env::var("PUNKTFUNK_SHOT_SETTLE_MS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(900); let scene = scene.to_string(); glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || { use std::io::Write as _; println!("PF_SHOT_READY scene={scene}"); let _ = std::io::stdout().flush(); }); } /// The trust gate in front of every connect. The host is the policy authority (it /// advertises `pair=optional` only when it accepts unpaired clients); the client renders /// its trust UI from that: /// 1. PINNED RECONNECT — a host already pinned to this exact fingerprint connects silently. /// 2. FINGERPRINT CHANGED — a host we know at this address but whose fingerprint no longer /// matches is the impostor signal: force re-pairing via the PIN ceremony, regardless of /// the advertised policy. /// 3. NEW host — TOFU is offered only when the host advertised `pair=optional` (rule 3a); /// otherwise (pair=required, unknown/empty policy, or a manual entry) PIN pairing is /// mandatory (rule 3b). /// /// A new host is never auto-connected without a stored pin or an explicit trust decision. fn initiate_connect(app: Rc, req: ConnectRequest) { if app.busy.get() { return; } let known = KnownHosts::load(); match &req.fp_hex { Some(fp_hex) => { if known.find_by_fp(fp_hex).is_some() { // Rule 1: pinned fingerprint matches — silent connect. start_session(app, req.clone(), crate::trust::parse_hex32(fp_hex)); } else if known.find_by_addr(&req.addr, req.port).is_some() { // Rule 2: we trust a host at this address but the fingerprint changed — // the impostor signal. Re-pair via the PIN ceremony (no TOFU shortcut). app.toast("Host fingerprint changed — re-pair with a PIN to continue"); pin_dialog(app, req); } else if req.pair_optional { // Rule 3a: the host opted into reduced-security TOFU; offer it alongside PIN. tofu_dialog(app, req); } else { // Rule 3b: pair=required or unknown policy — offer no-PIN delegated approval // (request access → approve in the console) or the PIN ceremony. approval_dialog(app, req); } } None => { // Manual entry (no advertised fingerprint). A known address connects silently // on its stored pin (rule 1); an unknown one must pair — request access (approve in // the console) or use a PIN; never silent TOFU. match known .find_by_addr(&req.addr, req.port) .and_then(|k| crate::trust::parse_hex32(&k.fp_hex)) { Some(pin) => start_session(app, req, Some(pin)), None => approval_dialog(app, req), // rule 3b } } } } /// First contact with a discovered host: show the advertised fingerprint and let the user /// trust it (TOFU), run the PIN ceremony instead, or walk away. fn tofu_dialog(app: Rc, req: ConnectRequest) { let fp = req.fp_hex.clone().unwrap_or_default(); let dialog = adw::AlertDialog::new( Some("New Host"), Some(&format!( "{} at {}:{}\n\nCertificate fingerprint:\n{}\n\nPairing with a PIN verifies it; \ trusting accepts it as-is.", req.name, req.addr, req.port, fp )), ); dialog.add_responses(&[ ("cancel", "Cancel"), ("pair", "Pair with PIN…"), ("trust", "Trust & Connect"), ]); dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested); dialog.set_default_response(Some("trust")); dialog.set_close_response("cancel"); let parent = app.window.clone(); dialog.connect_response(None, move |_, response| match response { "trust" => { let mut known = KnownHosts::load(); known.upsert(KnownHost { name: req.name.clone(), addr: req.addr.clone(), port: req.port, fp_hex: fp.clone(), paired: false, }); let _ = known.save(); start_session(app.clone(), req.clone(), crate::trust::parse_hex32(&fp)); } "pair" => pin_dialog(app.clone(), req.clone()), _ => {} }); dialog.present(Some(&parent)); } /// The SPAKE2 ceremony: the host is armed and displays a 4-digit PIN; proving knowledge /// of it pins the host's certificate (and registers ours) with no offline-guessable /// transcript. fn pin_dialog(app: Rc, req: ConnectRequest) { let entry = gtk::Entry::builder() .input_purpose(gtk::InputPurpose::Digits) .placeholder_text("4-digit PIN shown by the host") .activates_default(true) .build(); let dialog = adw::AlertDialog::new( Some("Pair with PIN"), Some(&format!( "Arm pairing on {} (console or web UI), then enter the PIN it displays.", req.name )), ); dialog.set_extra_child(Some(&entry)); dialog.add_responses(&[("cancel", "Cancel"), ("pair", "Pair")]); dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested); dialog.set_default_response(Some("pair")); dialog.set_close_response("cancel"); let parent = app.window.clone(); dialog.connect_response(Some("pair"), move |_, _| { let pin = entry.text().to_string(); let app = app.clone(); let req = req.clone(); let identity = app.identity.clone(); let (tx, rx) = async_channel::bounded::>(1); let (host, port, name) = (req.addr.clone(), req.port, glib::host_name().to_string()); std::thread::spawn(move || { let result = NativeClient::pair( &host, port, (&identity.0, &identity.1), pin.trim(), &name, std::time::Duration::from_secs(90), ) .map_err(|e| format!("Pairing failed: {e:?} (wrong PIN, or pairing not armed?)")); let _ = tx.send_blocking(result); }); glib::spawn_future_local(async move { match rx.recv().await { Ok(Ok(fp)) => { let fp_hex = crate::trust::hex(&fp); let mut known = KnownHosts::load(); known.upsert(KnownHost { name: req.name.clone(), addr: req.addr.clone(), port: req.port, fp_hex, paired: true, }); let _ = known.save(); app.toast("Paired — connecting…"); start_session(app.clone(), req, Some(fp)); } Ok(Err(msg)) => app.toast(&msg), Err(_) => {} } }); }); dialog.present(Some(&parent)); } /// A fresh host that requires pairing: offer the two ways in. "Request access" is the no-PIN /// path — connect and wait for the operator to click Approve in the host's console/web UI /// (delegated approval); "Use a PIN instead…" runs the SPAKE2 ceremony. fn approval_dialog(app: Rc, req: ConnectRequest) { let dialog = adw::AlertDialog::new( Some("Pairing Required"), Some(&format!( "{} requires pairing.\n\nRequest access and approve this device in the host's console \ (or web UI) — no PIN needed. Or pair with the 4-digit PIN it can display.", req.name )), ); dialog.add_responses(&[ ("cancel", "Cancel"), ("pin", "Use a PIN instead…"), ("request", "Request Access"), ]); dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested); dialog.set_default_response(Some("request")); dialog.set_close_response("cancel"); let parent = app.window.clone(); dialog.connect_response(None, move |_, response| match response { "request" => request_access(app.clone(), req.clone()), "pin" => pin_dialog(app.clone(), req.clone()), _ => {} }); dialog.present(Some(&parent)); } /// The no-PIN "request access" flow: open an identified connect that the host PARKS until the /// operator approves it in the console, showing a cancelable "waiting" dialog meanwhile. On /// approval the same connection is admitted (no reconnect) and the host is saved as paired. fn request_access(app: Rc, req: ConnectRequest) { // Pin the advertised certificate for a discovered host (defence against a host impostor while // we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use. let pin = req.fp_hex.as_deref().and_then(crate::trust::parse_hex32); let cancel = Rc::new(std::cell::Cell::new(false)); let waiting = adw::AlertDialog::new( Some("Waiting for Approval"), Some(&format!( "Approve “{}” in {}’s console or web UI.\n\nThis device is waiting to be let in — it \ connects automatically once you approve it.", glib::host_name(), req.name )), ); waiting.add_responses(&[("cancel", "Cancel")]); waiting.set_close_response("cancel"); { let app = app.clone(); let cancel = cancel.clone(); waiting.connect_response(Some("cancel"), move |_, _| { // Return the UI immediately; the in-flight connect is left to time out and is torn // down silently by the event loop (see StartOpts::cancel). cancel.set(true); app.busy.set(false); app.toast("Cancelled — the request may still be pending on the host."); }); } waiting.present(Some(&app.window)); start_session_with( app, req, pin, StartOpts { // Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator // approval still lands on this connection rather than timing the client out first. connect_timeout: std::time::Duration::from_secs(185), persist_paired: true, waiting: Some(waiting), cancel: Some(cancel), }, ); } /// 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 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(_) => {} } }); } /// The mode to request: explicit settings, with `0` fields resolved to the native /// size/refresh of the monitor the window currently occupies (mirrors the Swift client's /// native-display default). fn resolve_mode(app: &App) -> punktfunk_core::config::Mode { let s = app.settings.borrow(); let mut mode = punktfunk_core::config::Mode { width: s.width, height: s.height, refresh_hz: s.refresh_hz, }; if mode.width == 0 || mode.refresh_hz == 0 { // Prefer the monitor the window is on; fall back to the display's first monitor. On a // `--connect` launch the window may not be mapped yet when this runs, and without the // fallback we'd drop to the 1920×1080 floor below — wrong on the Deck (1280×800). let monitor = app .window .surface() .zip(gdk::Display::default()) .and_then(|(surf, d)| d.monitor_at_surface(&surf)) .or_else(|| { gdk::Display::default() .and_then(|d| d.monitors().item(0)) .and_then(|o| o.downcast::().ok()) }); if let Some(m) = monitor { let geo = m.geometry(); let scale = m.scale_factor().max(1); if mode.width == 0 { mode.width = (geo.width() * scale) as u32; mode.height = (geo.height() * scale) as u32; } if mode.refresh_hz == 0 { mode.refresh_hz = ((m.refresh_rate() + 500) / 1000).max(30) as u32; } } } // No monitor info (early call, odd compositor) — a sane floor. if mode.width == 0 { (mode.width, mode.height) = (1920, 1080); } if mode.refresh_hz == 0 { mode.refresh_hz = 60; } mode } /// Tunables for a session start that differ between the normal connect and the "request access" /// (delegated-approval) flow. `Default` is the normal connect. struct StartOpts { /// Handshake budget. The request-access flow uses a long one because the host PARKS the /// connection until the operator clicks Approve (see the host's `PENDING_APPROVAL_WAIT`). connect_timeout: std::time::Duration, /// Persist the host as *paired* on a successful connect. Set for request-access, where the /// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU /// persists the host *unpaired* (pinned, but not PIN/approval-verified). persist_paired: bool, /// A "waiting for approval" dialog to dismiss on the first session event (request-access only). waiting: Option, /// Set by the waiting dialog's Cancel button. `NativeClient::connect` is a blocking call with /// no abort, so Cancel returns the UI immediately (clears busy, closes the dialog) and leaves /// the in-flight connect to time out; when it finally resolves, the event loop sees this flag /// and tears down silently (drops the connector → closes the connection) without touching the /// UI a new session may already own. cancel: Option>>, } impl Default for StartOpts { fn default() -> Self { Self { connect_timeout: std::time::Duration::from_secs(15), persist_paired: false, waiting: None, cancel: None, } } } fn start_session(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>) { start_session_with(app, req, pin, StartOpts::default()); } fn start_session_with(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>, opts: StartOpts) { if app.busy.replace(true) { return; } let mode = resolve_mode(&app); let s = app.settings.borrow(); let params = SessionParams { host: req.addr.clone(), port: req.port, mode, compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto), // "Automatic" matches the physical pad (Swift parity); an explicit choice wins. gamepad: match GamepadPref::from_name(&s.gamepad) { Some(GamepadPref::Auto) | None => app.gamepad.auto_pref(), Some(explicit) => explicit, }, bitrate_kbps: s.bitrate_kbps, mic_enabled: s.mic_enabled, audio_channels: s.audio_channels, pin, identity: app.identity.clone(), connect_timeout: opts.connect_timeout, }; let inhibit = s.inhibit_shortcuts; drop(s); let tofu = pin.is_none(); let persist_paired = opts.persist_paired; let mut waiting = opts.waiting; let cancel = opts.cancel; let mut handle = crate::session::start(params); let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1); glib::spawn_future_local(async move { let mut frames = Some(frames); let mut page: Option = None; while let Ok(event) = handle.events.recv().await { // A cancelled request-access connect resolved late: tear down silently. Don't touch // app.busy — Cancel already cleared it, and a fresh session may now own it. if cancel.as_ref().is_some_and(|c| c.get()) { if let Some(w) = waiting.take() { w.close(); } break; } match event { SessionEvent::Connected { connector, mode, fingerprint, } => { // Dismiss the "waiting for approval" dialog (request-access flow), if any. if let Some(w) = waiting.take() { w.close(); } if persist_paired { // Request-access: the operator approved this device, so record the host as // a trusted PAIRED host (pinning the fingerprint we observed) — future // connects are then silent (rule 1), exactly like after a PIN ceremony. let fp_hex = crate::trust::hex(&fingerprint); let mut known = KnownHosts::load(); known.upsert(KnownHost { name: req.name.clone(), addr: req.addr.clone(), port: req.port, fp_hex, paired: true, }); let _ = known.save(); app.toast("Approved — connecting…"); } else if tofu { // A TOFU connect just observed the real fingerprint — pin it from now on. let fp_hex = crate::trust::hex(&fingerprint); let mut known = KnownHosts::load(); known.upsert(KnownHost { name: req.name.clone(), addr: req.addr.clone(), port: req.port, fp_hex: fp_hex.clone(), paired: false, }); let _ = known.save(); app.toast(&format!( "Trusted on first use — fingerprint {}…", &fp_hex[..16] )); } tracing::debug!(?mode, "connected — pushing stream page"); let title = format!( "{} · {}×{}@{}", req.name, mode.width, mode.height, mode.refresh_hz ); app.gamepad.attach(connector.clone()); let p = crate::ui_stream::new( &app.window, connector, frames.take().expect("Connected delivered once"), app.gamepad.escape_events(), handle.stop.clone(), inhibit, &title, ); app.nav.push(&p.page); // Steam Deck / Gaming Mode: gamescope fullscreens the window but GTK doesn't // know it, so its header bar stays drawn. Enter GTK fullscreen explicitly — // the stream page's `connect_fullscreened_notify` then hides all chrome. if app.fullscreen { app.window.fullscreen(); } page = Some(p); } SessionEvent::Stats(s) => { if let Some(p) = &page { p.update_stats(s); } } SessionEvent::Failed { msg, trust_rejected, } => { if let Some(w) = waiting.take() { w.close(); } tracing::warn!(%msg, trust_rejected, "connect failed"); app.busy.set(false); // A pinned connect rejected on trust grounds means the host's cert no // longer matches the stored pin (rotated cert or impostor) — route to // the PIN ceremony to re-establish trust rather than dead-ending. if trust_rejected && !tofu { app.toast("Host fingerprint changed — re-pair with a PIN to continue"); pin_dialog(app.clone(), req.clone()); } else { app.toast(&msg); } break; } SessionEvent::Ended(err) => { if let Some(w) = waiting.take() { w.close(); } app.gamepad.detach(); app.nav.pop_to_tag("hosts"); if let Some(e) = err { app.toast(&e); } app.busy.set(false); break; } } } }); }