e8196b33b8
windows-host / package (push) Successful in 6m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m5s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m6s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 47s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 54s
apple / swift (push) Successful in 1m17s
audit / cargo-audit (push) Successful in 17s
android / android (push) Successful in 3m46s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 57s
release / apple (push) Successful in 8m41s
deb / build-publish (push) Has been cancelled
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / rust (push) Successful in 8m21s
Root-caused fixes from on-Deck testing (owner + first external tester): - System input broke while the app was merely OPEN: SDL's Steam Deck HIDAPI driver clears the built-in controller's "lizard mode" (trackpad-mouse, clicky pads) at device ENUMERATION and keeps feeding the firmware watchdog (SDL_hidapi_steamdeck.c InitDevice/UpdateDevice) — and we enabled that driver at startup and held every pad open app-lifetime. The Valve HIDAPI hints are now enabled only while a session is attached, and only the active pad is opened (Settings enumerates via SDL's ID-based metadata getters, no open). Close/detach hands the hardware back; the watchdog restores lizard mode within seconds. This also unblocks click-to-capture on the Deck (the dead trackpad made "input not passed through" a symptom, not a cause). - Washed-out colors from a Windows host with an HDR desktop: the host ships Main10 BT.2020 PQ IN-BAND (correct VUI) while the Welcome still says SDR; this client rendered everything as BT.709 narrow. Colour signaling is now read per-frame (video::ColorDesc from the AVFrame CICP fields) and drives the GdkDmabufTexture color state, the software path's swscale matrix/range plus a tagged MemoryTexture for PQ, and an "· HDR" HUD chip — GTK tone-maps correctly on SDR displays, mid-session SDR↔HDR flips included. Regression- tested against a checked-in Main10 PQ fixture (tests/pq-frame.h265). - Streams start fullscreen by default (Settings toggle; F11 / the controller chord lead out, and the pointer at the top edge reveals the header while input isn't captured — a Deck desktop has no F11). Gaming-Mode launches (--fullscreen / Deck env) build the stream page with NO header bar at all: gamescope doesn't reliably ACK xdg_toplevel fullscreen, so anything keyed on is_fullscreen() could leave the title bar drawn over the stream. - Game Mode settings were uneditable: GTK popovers are xdg_popups, which gamescope never maps for nested apps — every ComboRow dropdown flashed and died. Under gamescope the preferences dialog now uses in-window selection subpages (PreferencesDialog::push_subpage) via a ChoiceRow that stays a stock ComboRow on desktops. Covered by an in-process GTK test (choice_row_modes, #[ignore]d — needs a display). - Forwarded-controller pin persists across restarts (Settings::forward_pad, stable vid:pid:name key — SDL instance ids are per-run) and survives disconnects; automatic selection skips Steam Input's sensor-less virtual pad (28de:11ff) so gyro doesn't silently die on Bazzite/Deck. - "Punktfunk" branding in the About dialog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
324 lines
13 KiB
Rust
324 lines
13 KiB
Rust
//! 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::<gdk::Monitor>().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<adw::AlertDialog>,
|
||
/// 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<Rc<std::cell::Cell<bool>>>,
|
||
}
|
||
|
||
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<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
||
start_session_with(app, req, pin, StartOpts::default());
|
||
}
|
||
|
||
pub fn start_session_with(
|
||
app: Rc<App>,
|
||
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<App>,
|
||
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<AtomicBool>,
|
||
/// Decoded-frame receiver, handed to the stream page once on `Connected`.
|
||
frames: Option<async_channel::Receiver<DecodedFrame>>,
|
||
/// The "waiting for approval" dialog (request-access flow), dismissed on the first event.
|
||
waiting: Option<adw::AlertDialog>,
|
||
page: Option<crate::ui_stream::StreamPage>,
|
||
}
|
||
|
||
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<NativeClient>, 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<String>) {
|
||
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);
|
||
}
|
||
}
|