From e9c1f4083af111071df8f0fec2eb961706e004c7 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 3 Jul 2026 17:16:26 +0000 Subject: [PATCH] =?UTF-8?q?fix(client-linux):=20Deck=20Gaming=20Mode=20?= =?UTF-8?q?=E2=80=94=20auto=20pad=20type,=20real=20chrome-less=20fullscree?= =?UTF-8?q?n,=20leave-to-Gaming-Mode,=20colour=20bisect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Automatic" gamepad type resolves to the virtual Steam Deck pad on Deck hardware (env SteamDeck / DMI Jupiter|Galileo): the built-in 28DE:1205 identity is invisible at Hello time — the Valve HIDAPI drivers run in-session only and Steam Input shadows the pad with its virtual X360 — so auto always fell through to Xbox 360. "steamdeck" is now also selectable in Settings. - Chrome-less launches flatten the window CSS (border-radius/box-shadow) and fullscreen at startup: gamescope never ACKs the xdg fullscreen state, so adwaita kept the floating-CSD rounded corners + shadow visible over the stream. - Gaming-Mode --connect launches quit on session end, so Steam ends the "game" and the Deck returns to Gaming Mode — previously the app popped to its own hosts page, stranding the user fullscreen and making the escape chord read as broken. - The capture hint is controller-aware; the chromeless hint teaches the hold-chord ("hold L1+R1+Start+Select to leave") and a quick chord press re-flashes it. - Colour bisect for the reported off-colours on the VAAPI dmabuf path: graphics offload defaults OFF under gamescope (a subsurface hands the NV12 CSC to the compositor), PUNKTFUNK_OFFLOAD=1|0 overrides, and each colour-signaling change logs whether GDK accepted the BT.709-narrow color state (fallback = GDK's BT.601 dmabuf default). Co-Authored-By: Claude Fable 5 --- clients/linux/src/app.rs | 19 +++++++- clients/linux/src/gamepad.rs | 26 +++++++++++ clients/linux/src/launch.rs | 13 ++++++ clients/linux/src/ui_settings.rs | 10 ++++- clients/linux/src/ui_stream.rs | 74 ++++++++++++++++++++++++++------ 5 files changed, 128 insertions(+), 14 deletions(-) diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index 918267c..c28e3a7 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -30,6 +30,10 @@ 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; } "; pub struct App { @@ -44,6 +48,10 @@ pub struct App { 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>>, @@ -116,6 +124,14 @@ fn build_ui(gtk_app: &adw::Application) { .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(), @@ -124,7 +140,8 @@ fn build_ui(gtk_app: &adw::Application) { identity, gamepad: crate::gamepad::GamepadService::start(), busy: std::cell::Cell::new(false), - fullscreen: crate::cli::fullscreen_mode(), + fullscreen, + quit_on_session_end: fullscreen && crate::cli::cli_connect_request().is_some(), hosts: RefCell::new(None), }); diff --git a/clients/linux/src/gamepad.rs b/clients/linux/src/gamepad.rs index a9bfd1c..090a470 100644 --- a/clients/linux/src/gamepad.rs +++ b/clients/linux/src/gamepad.rs @@ -114,6 +114,21 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref { } } +/// Best-effort "this machine is a Steam Deck". The Gaming-Mode env short-circuits; desktop +/// mode falls back to DMI (Valve board, Jupiter = LCD / Galileo = OLED — readable inside the +/// flatpak sandbox). Cached: the answer can't change while we run. +pub fn is_steam_deck() -> bool { + static DECK: std::sync::OnceLock = std::sync::OnceLock::new(); + *DECK.get_or_init(|| { + if std::env::var_os("SteamDeck").is_some() { + return true; + } + let dmi = |f: &str| std::fs::read_to_string(format!("/sys/class/dmi/id/{f}")); + dmi("board_vendor").is_ok_and(|v| v.trim() == "Valve") + && dmi("product_name").is_ok_and(|p| matches!(p.trim(), "Jupiter" | "Galileo")) + }) +} + enum Ctl { Attach(Arc), Detach, @@ -197,8 +212,19 @@ impl GamepadService { /// What "Automatic" resolves to right now — the virtual pad matching the physical one /// (Swift parity); no pad connected leaves the host's own default. + /// + /// **Steam Deck special case:** this is read at session start, *before* attach — but the + /// Deck's built-in controller is only enumerable with its real 28DE:1205 identity while + /// the Valve HIDAPI drivers run, and those are enabled on attach only (see + /// [`set_valve_hidapi`]); with Steam Input on, SDL sees nothing but Steam's virtual + /// X360 pad anyway. Both cases used to fall through to Xbox 360. On a Deck, a virtual + /// pad (or no pad at all) means the physical controller behind it IS the built-in one — + /// resolve to the Steam Deck virtual pad so the paddles/trackpads/gyro have somewhere + /// to land. A real external controller still wins (it's the one that gets forwarded). pub fn auto_pref(&self) -> GamepadPref { match self.active() { + Some(p) if !p.steam_virtual => p.pref, + _ if is_steam_deck() => GamepadPref::SteamDeck, Some(p) => p.pref, None => GamepadPref::Auto, } diff --git a/clients/linux/src/launch.rs b/clients/linux/src/launch.rs index 81629fa..58511e1 100644 --- a/clients/linux/src/launch.rs +++ b/clients/linux/src/launch.rs @@ -266,6 +266,9 @@ impl SessionUi { 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); @@ -311,6 +314,16 @@ impl SessionUi { 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; + } self.app.nav.pop_to_tag("hosts"); if let Some(h) = self.app.hosts_ui() { h.set_connecting(None); diff --git a/clients/linux/src/ui_settings.rs b/clients/linux/src/ui_settings.rs index 9e9d7e0..bed1297 100644 --- a/clients/linux/src/ui_settings.rs +++ b/clients/linux/src/ui_settings.rs @@ -16,7 +16,14 @@ const RESOLUTIONS: &[(u32, u32)] = &[ ]; /// `0` = the monitor's native refresh, resolved at connect. const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240]; -const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense", "xboxone", "dualshock4"]; +const GAMEPADS: &[&str] = &[ + "auto", + "xbox360", + "dualsense", + "xboxone", + "dualshock4", + "steamdeck", +]; const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]; /// Codec setting values (persisted) paired with their display labels below. const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"]; @@ -403,6 +410,7 @@ pub fn show( "DualSense", "Xbox One", "DualShock 4", + "Steam Deck", ], ); let inhibit_row = adw::SwitchRow::builder() diff --git a/clients/linux/src/ui_stream.rs b/clients/linux/src/ui_stream.rs index a2013e9..550492a 100644 --- a/clients/linux/src/ui_stream.rs +++ b/clients/linux/src/ui_stream.rs @@ -84,6 +84,9 @@ pub struct StreamPageArgs { /// 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, } @@ -197,9 +200,10 @@ pub fn new(args: StreamPageArgs) -> StreamPage { inhibit_shortcuts, show_stats, chromeless, + pad_connected, title, } = args; - let w = build_widgets(&window, &title, chromeless); + let w = build_widgets(&window, &title, chromeless, pad_connected); w.stats_label.set_visible(show_stats); let capture = Rc::new(Capture { @@ -230,7 +234,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage { attach_edge_reveal(&w.toolbar, &w.overlay, &window, &capture); } let active_handler = attach_capture_lifecycle(&w.overlay, &window, &capture); - let escape_future = spawn_escape_watch(&window, &capture, escape_rx); + 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, @@ -254,6 +258,9 @@ 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, @@ -264,7 +271,12 @@ struct PageWidgets { /// 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) -> PageWidgets { +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); @@ -273,6 +285,22 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str, chromeless: bool) // 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"); @@ -282,9 +310,16 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str, chromeless: bool) stats_label.set_margin_start(12); stats_label.set_margin_top(12); - let hint = gtk::Label::new(Some( - "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats", - )); + // 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" + })); hint.add_css_class("osd"); hint.set_halign(gtk::Align::Center); hint.set_valign(gtk::Align::End); @@ -296,7 +331,7 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str, chromeless: bool) // 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 { - "L1 + R1 + Start + Select — leave the stream (hold to disconnect)" + "Hold L1 + R1 + Start + Select — leave the stream" } else { "F11 · mouse to the top edge · L1 + R1 + Start + Select — exit fullscreen (hold to disconnect)" })); @@ -372,6 +407,7 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str, chromeless: bool) picture, stats_label, hint, + fs_hint, overlay, toolbar, page, @@ -461,11 +497,15 @@ impl ColorStateCache { }); } let state = cicp.build_color_state().ok(); - if state.is_none() { - tracing::warn!( + // 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, - "GDK can't represent this colour signaling — using default" - ); + rgb, + "GDK can't represent this colour signaling — using default (YUV: BT.601)" + ), } self.0 = Some((desc, state.clone())); state @@ -772,20 +812,30 @@ fn attach_capture_lifecycle( /// 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). Aborted on page-hidden so a stale future can't act on the shared window. +/// 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)); + } } }) }