fix(client-linux): Deck Gaming Mode — auto pad type, real chrome-less fullscreen, leave-to-Gaming-Mode, colour bisect

- "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 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 17:16:26 +00:00
parent 20f0d2802f
commit e9c1f4083a
5 changed files with 128 additions and 14 deletions
+18 -1
View File
@@ -30,6 +30,10 @@ const CSS: &str = "
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); } .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-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); } .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 { pub struct App {
@@ -44,6 +48,10 @@ pub struct App {
pub busy: std::cell::Cell<bool>, pub busy: std::cell::Cell<bool>,
/// Steam Deck / Gaming-Mode launch: fullscreen the window (chrome-less) when a stream starts. /// Steam Deck / Gaming-Mode launch: fullscreen the window (chrome-less) when a stream starts.
pub fullscreen: bool, 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 /// The hosts page handle (banner + per-card connecting spinner), set right after the
/// page is built — `None` only during construction. /// page is built — `None` only during construction.
pub hosts: RefCell<Option<Rc<HostsUi>>>, pub hosts: RefCell<Option<Rc<HostsUi>>>,
@@ -116,6 +124,14 @@ fn build_ui(gtk_app: &adw::Application) {
.content(&toasts) .content(&toasts)
.build(); .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 { let app = Rc::new(App {
window: window.clone(), window: window.clone(),
nav: nav.clone(), nav: nav.clone(),
@@ -124,7 +140,8 @@ fn build_ui(gtk_app: &adw::Application) {
identity, identity,
gamepad: crate::gamepad::GamepadService::start(), gamepad: crate::gamepad::GamepadService::start(),
busy: std::cell::Cell::new(false), 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), hosts: RefCell::new(None),
}); });
+26
View File
@@ -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<bool> = 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 { enum Ctl {
Attach(Arc<NativeClient>), Attach(Arc<NativeClient>),
Detach, Detach,
@@ -197,8 +212,19 @@ impl GamepadService {
/// What "Automatic" resolves to right now — the virtual pad matching the physical one /// What "Automatic" resolves to right now — the virtual pad matching the physical one
/// (Swift parity); no pad connected leaves the host's own default. /// (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 { pub fn auto_pref(&self) -> GamepadPref {
match self.active() { match self.active() {
Some(p) if !p.steam_virtual => p.pref,
_ if is_steam_deck() => GamepadPref::SteamDeck,
Some(p) => p.pref, Some(p) => p.pref,
None => GamepadPref::Auto, None => GamepadPref::Auto,
} }
+13
View File
@@ -266,6 +266,9 @@ impl SessionUi {
inhibit_shortcuts: self.inhibit, inhibit_shortcuts: self.inhibit,
show_stats: self.show_stats, show_stats: self.show_stats,
chromeless: self.app.fullscreen, 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, title,
}); });
self.app.nav.push(&p.page); self.app.nav.push(&p.page);
@@ -311,6 +314,16 @@ impl SessionUi {
fn on_ended(&mut self, err: Option<String>) { fn on_ended(&mut self, err: Option<String>) {
self.close_waiting(); self.close_waiting();
self.app.gamepad.detach(); 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"); self.app.nav.pop_to_tag("hosts");
if let Some(h) = self.app.hosts_ui() { if let Some(h) = self.app.hosts_ui() {
h.set_connecting(None); h.set_connecting(None);
+9 -1
View File
@@ -16,7 +16,14 @@ const RESOLUTIONS: &[(u32, u32)] = &[
]; ];
/// `0` = the monitor's native refresh, resolved at connect. /// `0` = the monitor's native refresh, resolved at connect.
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240]; 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"]; const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
/// Codec setting values (persisted) paired with their display labels below. /// Codec setting values (persisted) paired with their display labels below.
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"]; const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
@@ -403,6 +410,7 @@ pub fn show(
"DualSense", "DualSense",
"Xbox One", "Xbox One",
"DualShock 4", "DualShock 4",
"Steam Deck",
], ],
); );
let inhibit_row = adw::SwitchRow::builder() let inhibit_row = adw::SwitchRow::builder()
+62 -12
View File
@@ -84,6 +84,9 @@ pub struct StreamPageArgs {
/// reveal-on-notify chrome hiding) may never fire — the title bar would stay drawn /// 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. /// over the stream. Chrome-less by construction cannot regress that way.
pub chromeless: bool, 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, pub title: String,
} }
@@ -197,9 +200,10 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
inhibit_shortcuts, inhibit_shortcuts,
show_stats, show_stats,
chromeless, chromeless,
pad_connected,
title, title,
} = args; } = args;
let w = build_widgets(&window, &title, chromeless); let w = build_widgets(&window, &title, chromeless, pad_connected);
w.stats_label.set_visible(show_stats); w.stats_label.set_visible(show_stats);
let capture = Rc::new(Capture { let capture = Rc::new(Capture {
@@ -230,7 +234,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
attach_edge_reveal(&w.toolbar, &w.overlay, &window, &capture); attach_edge_reveal(&w.toolbar, &w.overlay, &window, &capture);
} }
let active_handler = attach_capture_lifecycle(&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); let disconnect_future = spawn_disconnect_watch(&window, &capture, &stop, disconnect_rx);
wire_teardown( wire_teardown(
&w.page, &w.page,
@@ -254,6 +258,9 @@ struct PageWidgets {
picture: gtk::Picture, picture: gtk::Picture,
stats_label: gtk::Label, stats_label: gtk::Label,
hint: 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, overlay: gtk::Overlay,
toolbar: adw::ToolbarView, toolbar: adw::ToolbarView,
page: adw::NavigationPage, page: adw::NavigationPage,
@@ -264,7 +271,12 @@ struct PageWidgets {
/// The offloaded picture under an overlay (stats HUD, capture hint, fullscreen hint), a /// 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. /// header bar with the fullscreen toggle, and the window's fullscreen behavior.
/// `chromeless` (Gaming Mode) builds NO header bar at all — see `StreamPageArgs`. /// `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(); let picture = gtk::Picture::new();
picture.set_content_fit(gtk::ContentFit::Contain); 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. // no-op wrapper. Black letterboxing keeps fullscreen scanout-eligible.
let offload = gtk::GraphicsOffload::new(Some(&picture)); let offload = gtk::GraphicsOffload::new(Some(&picture));
offload.set_black_background(true); 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); let stats_label = gtk::Label::new(None);
stats_label.add_css_class("osd"); 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_start(12);
stats_label.set_margin_top(12); stats_label.set_margin_top(12);
let hint = gtk::Label::new(Some( // The capture hint speaks the input devices actually present: on a controller-first
"Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats", // 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.add_css_class("osd");
hint.set_halign(gtk::Align::Center); hint.set_halign(gtk::Align::Center);
hint.set_valign(gtk::Align::End); 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, // 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. // no header to reveal, and Steam owns window management — only the chord applies.
let fs_hint = gtk::Label::new(Some(if chromeless { 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 { } else {
"F11 · mouse to the top edge · L1 + R1 + Start + Select — exit fullscreen (hold to disconnect)" "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, picture,
stats_label, stats_label,
hint, hint,
fs_hint,
overlay, overlay,
toolbar, toolbar,
page, page,
@@ -461,11 +497,15 @@ impl ColorStateCache {
}); });
} }
let state = cicp.build_color_state().ok(); let state = cicp.build_color_state().ok();
if state.is_none() { // One line per signaling change — the on-glass colour bisect reads this to tell
tracing::warn!( // "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, ?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())); self.0 = Some((desc, state.clone()));
state state
@@ -772,20 +812,30 @@ fn attach_capture_lifecycle(
/// Controller escape chord (gamepad service) → leave fullscreen + release capture. The /// 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 /// 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( fn spawn_escape_watch(
window: &adw::ApplicationWindow, window: &adw::ApplicationWindow,
capture: &Rc<Capture>, capture: &Rc<Capture>,
escape_rx: async_channel::Receiver<()>, escape_rx: async_channel::Receiver<()>,
fs_hint: &gtk::Label,
chromeless: bool,
) -> glib::JoinHandle<()> { ) -> glib::JoinHandle<()> {
let window = window.clone(); let window = window.clone();
let cap = capture.clone(); let cap = capture.clone();
let fs_hint = fs_hint.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
while escape_rx.recv().await.is_ok() { while escape_rx.recv().await.is_ok() {
if window.is_fullscreen() { if window.is_fullscreen() {
window.unfullscreen(); window.unfullscreen();
} }
cap.release(); 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));
}
} }
}) })
} }