Files
punktfunk/clients/linux/src/launch.rs
T
enricobuehler df496776b0
apple / swift (push) Successful in 1m14s
apple / screenshots (push) Successful in 5m41s
android / android (push) Successful in 3m46s
ci / web (push) Successful in 49s
ci / rust (push) Successful in 1m23s
ci / docs-site (push) Successful in 58s
ci / bench (push) Successful in 4m52s
deb / build-publish (push) Successful in 4m34s
decky / build-publish (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
flatpak / build-publish (push) Successful in 4m29s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m58s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m8s
fix(client-linux): Deck raw-pad capture — clear Steam's SDL device filter, honest degradation warning
The Deck's built-in controller can never leave Steam Input ("Steam Controller"
is always-required in the shortcut's matrix; Disable Steam Input only affects
other controller brands), so the raw 28DE:1205 device is the only path to the
trackpads/paddles/gyro. Steam hides it from SDL by launching shortcuts with
SDL_GAMECONTROLLER_IGNORE_DEVICES naming every physical pad it virtualized —
clear it (and _EXCEPT) at startup while single-threaded, logging what Steam set
as field evidence. The post-attach warning now states the real condition (raw
pad never enumerated; sticks + buttons still work) instead of advising a
Steam Input toggle that doesn't exist for the built-in controller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:06:48 +00:00

388 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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,
// 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<String>) {
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);
}
}