Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e088af7ed | ||
|
|
b7a00137eb | ||
|
|
acce43ebbf | ||
|
|
35923080fb | ||
|
|
0c9461242c | ||
|
|
cc6b37fef0 | ||
|
|
1927077cbe | ||
|
|
068da456f5 | ||
|
|
256aa5f7f2 | ||
|
|
2562663fc6 |
@@ -876,10 +876,16 @@ impl Worker {
|
||||
);
|
||||
return;
|
||||
};
|
||||
let pref = self
|
||||
.pad_info(id)
|
||||
.map(|p| p.pref)
|
||||
.unwrap_or(GamepadPref::Xbox360);
|
||||
let pref = match self.pad_info(id) {
|
||||
// Steam Input's virtual pad standing in front of the Deck's built-in controls (the
|
||||
// only-pad-forwarded case, [`Self::forwarded_ids`]): declare the DECK kind, not the
|
||||
// wrapper's Xbox 360 identity. [`Self::auto_pref`] already resolves the SESSION
|
||||
// default this way, but a current host honors the per-pad arrival over the session
|
||||
// default — so without this the host builds an X-Box 360 pad on a real Deck.
|
||||
Some(p) if p.steam_virtual && is_steam_deck() => GamepadPref::SteamDeck,
|
||||
Some(p) => p.pref,
|
||||
None => GamepadPref::Xbox360,
|
||||
};
|
||||
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
|
||||
Ok(pad) => {
|
||||
let mut slot = Slot::new(id, index, pref, pad);
|
||||
|
||||
@@ -325,6 +325,11 @@ pub fn connect_reject_message(reason: punktfunk_core::reject::RejectReason) -> S
|
||||
"Client and host versions don't match — update both to the same release.".into()
|
||||
}
|
||||
R::Busy => "The host is busy with another session.".into(),
|
||||
R::SetupFailed => {
|
||||
"The host accepted the connection but couldn't start the stream — the host's log \
|
||||
(web console → Log) has the cause."
|
||||
.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ async fn session_main(mut rx: UnboundedReceiver<InputEvent>, source: EiSource) {
|
||||
// Keep `_rd`/`_session` bound for the whole loop — dropping the portal session closes the
|
||||
// EIS connection. Bound the setup so a headless approval dialog (un-bypassed grant) can't
|
||||
// hang the worker forever.
|
||||
let (_keepalive, context, mut events) = match tokio::time::timeout(
|
||||
let (_keepalive, context, mut events, output_hint) = match tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
connect(source),
|
||||
)
|
||||
@@ -130,6 +130,7 @@ async fn session_main(mut rx: UnboundedReceiver<InputEvent>, source: EiSource) {
|
||||
tracing::info!("libei: EIS connected — awaiting devices");
|
||||
|
||||
let mut state = EiState::new();
|
||||
state.output_hint = output_hint;
|
||||
// Watchdog: a healthy EIS server adds + resumes an input device within a beat of the handshake.
|
||||
// If none has resumed by this deadline, the connection is dead-on-arrival (stale/half-ready
|
||||
// gamescope socket the handshake passed but no real server is behind) — exit so the next
|
||||
@@ -177,21 +178,29 @@ type Connected = (
|
||||
Box<dyn Send>,
|
||||
ei::Context,
|
||||
reis::tokio::EiConvertEventStream,
|
||||
// The compositor's output size ("WxH" relay-file hint) — the scale target for absolute
|
||||
// coordinates when the EIS advertises only a degenerate region (gamescope). `None` for
|
||||
// the portal/Mutter paths, whose regions are real.
|
||||
Option<(u32, u32)>,
|
||||
);
|
||||
|
||||
/// Reach an EIS server per `source` and run the EI sender handshake.
|
||||
async fn connect(source: EiSource) -> Result<Connected> {
|
||||
let (keepalive, stream): (Box<dyn Send>, UnixStream) = match source {
|
||||
EiSource::Portal => {
|
||||
let (rd, session, fd) = connect_portal().await?;
|
||||
(Box::new((rd, session)), UnixStream::from(fd))
|
||||
}
|
||||
EiSource::MutterEis => {
|
||||
let (keepalive, fd) = connect_mutter().await?;
|
||||
(keepalive, UnixStream::from(fd))
|
||||
}
|
||||
EiSource::SocketPathFile(file) => (Box::new(()), connect_socket_file(&file).await?),
|
||||
};
|
||||
let (keepalive, stream, output_hint): (Box<dyn Send>, UnixStream, Option<(u32, u32)>) =
|
||||
match source {
|
||||
EiSource::Portal => {
|
||||
let (rd, session, fd) = connect_portal().await?;
|
||||
(Box::new((rd, session)), UnixStream::from(fd), None)
|
||||
}
|
||||
EiSource::MutterEis => {
|
||||
let (keepalive, fd) = connect_mutter().await?;
|
||||
(keepalive, UnixStream::from(fd), None)
|
||||
}
|
||||
EiSource::SocketPathFile(file) => {
|
||||
let (stream, hint) = connect_socket_file(&file).await?;
|
||||
(Box::new(()), stream, hint)
|
||||
}
|
||||
};
|
||||
let context = ei::Context::new(stream).map_err(|e| anyhow!("reis EI context: {e}"))?;
|
||||
// Bound the handshake. `UnixStream::connect` to a socket *file* succeeds the moment the path
|
||||
// exists, but a stale/half-ready gamescope (its socket created early in startup, or left behind
|
||||
@@ -206,7 +215,7 @@ async fn connect(source: EiSource) -> Result<Connected> {
|
||||
anyhow!("EI handshake timed out (EIS server not responding — stale/half-ready socket?)")
|
||||
})?
|
||||
.map_err(|e| anyhow!("EI handshake: {e}"))?;
|
||||
Ok((keepalive, context, events))
|
||||
Ok((keepalive, context, events, output_hint))
|
||||
}
|
||||
|
||||
/// Open a RemoteDesktop portal session (pointer + keyboard) and obtain the EIS socket fd.
|
||||
@@ -294,8 +303,10 @@ async fn connect_mutter() -> Result<(Box<dyn Send>, std::os::fd::OwnedFd)> {
|
||||
|
||||
/// Poll `file` for the EIS socket path (the gamescope backend relays `LIBEI_SOCKET` there once
|
||||
/// the nested app launches), then connect. A bare name is resolved against `XDG_RUNTIME_DIR`,
|
||||
/// mirroring libei's own `LIBEI_SOCKET` semantics.
|
||||
async fn connect_socket_file(file: &std::path::Path) -> Result<UnixStream> {
|
||||
/// mirroring libei's own `LIBEI_SOCKET` semantics. Line 2 of the relay file, when present,
|
||||
/// carries the compositor's output size as `WxH` — returned as the absolute-coordinate scale
|
||||
/// hint (gamescope's EIS region is degenerate, so the geometry can't come from the protocol).
|
||||
async fn connect_socket_file(file: &std::path::Path) -> Result<(UnixStream, Option<(u32, u32)>)> {
|
||||
// The relay file is rewritten each session with the CURRENT gamescope's `LIBEI_SOCKET`, and the
|
||||
// socket may not be `listen()`ing the instant its name appears — or the file may briefly still
|
||||
// hold a prior, now-dead session's name (the host-lifetime injector reconnecting between
|
||||
@@ -319,7 +330,12 @@ async fn connect_socket_file(file: &std::path::Path) -> Result<UnixStream> {
|
||||
));
|
||||
}
|
||||
if let Ok(s) = std::fs::read_to_string(file) {
|
||||
let name = s.trim();
|
||||
let mut file_lines = s.lines();
|
||||
let name = file_lines.next().unwrap_or("").trim();
|
||||
let hint = file_lines.next().and_then(|l| {
|
||||
let (w, h) = l.trim().split_once('x')?;
|
||||
Some((w.parse::<u32>().ok()?, h.parse::<u32>().ok()?))
|
||||
});
|
||||
if !name.is_empty() {
|
||||
let full = if name.starts_with('/') {
|
||||
std::path::PathBuf::from(name)
|
||||
@@ -334,7 +350,7 @@ async fn connect_socket_file(file: &std::path::Path) -> Result<UnixStream> {
|
||||
logged = name.to_string();
|
||||
}
|
||||
match UnixStream::connect(&full) {
|
||||
Ok(stream) => return Ok(stream),
|
||||
Ok(stream) => return Ok((stream, hint)),
|
||||
// Refused = socket file exists but no listener yet (or a dead session);
|
||||
// NotFound = path not created yet. Both heal once the live gamescope's EIS is
|
||||
// up — retry. Anything else (e.g. permission) is a real failure.
|
||||
@@ -386,6 +402,22 @@ struct EiState {
|
||||
held_keys: Vec<u32>,
|
||||
held_buttons: Vec<u32>,
|
||||
held_touches: Vec<u32>,
|
||||
/// The touch id currently degraded to the absolute pointer ([`EiState::degrade_touch`]) —
|
||||
/// the "primary finger" while the EIS has no touchscreen device. `None` between touches.
|
||||
degraded_touch: Option<u32>,
|
||||
/// The compositor's output size (relay-file "WxH" hint) — scale target for absolute
|
||||
/// coordinates when the device's region is degenerate/absent (gamescope). Without it the
|
||||
/// fallback is raw client pixels, correct only when the stream runs at the output's size.
|
||||
output_hint: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// Is this EIS region a plausible OUTPUT geometry — something to map normalized coordinates
|
||||
/// into? gamescope advertises a degenerate `(0,0,INT32_MAX,INT32_MAX)` "everything" region on
|
||||
/// its virtual input device, meaning "absolute coordinates are raw"; normalizing into it
|
||||
/// explodes a center tap to x≈1e9, which the compositor clamps to the far corner. 16384
|
||||
/// comfortably covers real multi-monitor layouts while rejecting the sentinel.
|
||||
fn sane_region(r: &reis::event::Region) -> bool {
|
||||
r.width > 0 && r.height > 0 && r.width <= 16_384 && r.height <= 16_384
|
||||
}
|
||||
|
||||
/// Stable small index per [`InputKind`] for the `seen_kinds` bitmask.
|
||||
@@ -422,6 +454,8 @@ impl EiState {
|
||||
held_keys: Vec::new(),
|
||||
held_buttons: Vec::new(),
|
||||
held_touches: Vec::new(),
|
||||
degraded_touch: None,
|
||||
output_hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,6 +464,10 @@ impl EiState {
|
||||
/// normal [`EiState::inject`] path so the compositor sees proper key-up / button-up /
|
||||
/// touch-up frames before the devices disappear.
|
||||
fn release_all(&mut self, ctx: &ei::Context) {
|
||||
// A degraded touch is held as a synthesized left button (in `held_buttons`), so the
|
||||
// loop below releases it — but the primary-finger latch must clear too, or the NEXT
|
||||
// session's first TouchDown reads as a second finger and is ignored.
|
||||
self.degraded_touch = None;
|
||||
let (keys, buttons, touches) = (
|
||||
std::mem::take(&mut self.held_keys),
|
||||
std::mem::take(&mut self.held_buttons),
|
||||
@@ -506,6 +544,8 @@ impl EiState {
|
||||
keyboard = dev.has_capability(DeviceCapability::Keyboard),
|
||||
button = dev.has_capability(DeviceCapability::Button),
|
||||
scroll = dev.has_capability(DeviceCapability::Scroll),
|
||||
regions = dev.regions().len(),
|
||||
region0 = ?dev.regions().first().map(|r| (r.x, r.y, r.width, r.height)),
|
||||
"libei: device RESUMED (now emittable)"
|
||||
);
|
||||
}
|
||||
@@ -537,8 +577,85 @@ impl EiState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Degrade touch to a single-finger ABSOLUTE POINTER on compositors whose EIS never
|
||||
/// creates a touchscreen device (gamescope's "Gamescope Virtual Input" advertises
|
||||
/// pointer/pointer_abs/button but no touch — observed live; headless KWin the same).
|
||||
/// Down = abs-move + left press, move = abs-move, up = left release — synthesized through
|
||||
/// the normal [`EiState::inject`] Mouse* paths so region mapping, held-state tracking and
|
||||
/// [`EiState::release_all`] all apply. Only the FIRST finger drives the pointer; later
|
||||
/// fingers are ignored (a pointer has no second contact — a pinch degrades to a drag).
|
||||
fn degrade_touch(&mut self, ev: &InputEvent, ctx: &ei::Context) {
|
||||
const GS_BUTTON_LEFT: u32 = 1;
|
||||
match ev.kind {
|
||||
InputKind::TouchDown => {
|
||||
if self.degraded_touch.is_some() {
|
||||
return; // secondary finger — single-pointer degradation
|
||||
}
|
||||
self.degraded_touch = Some(ev.code);
|
||||
static NOTED: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
if !NOTED.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
tracing::info!(
|
||||
"compositor's EIS has no touchscreen device — degrading touch to a \
|
||||
single-finger absolute pointer (tap = left click; multi-touch \
|
||||
gestures unavailable)"
|
||||
);
|
||||
}
|
||||
self.inject(
|
||||
&InputEvent {
|
||||
kind: InputKind::MouseMoveAbs,
|
||||
..*ev
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
self.inject(
|
||||
&InputEvent {
|
||||
kind: InputKind::MouseButtonDown,
|
||||
code: GS_BUTTON_LEFT,
|
||||
..*ev
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
InputKind::TouchMove if self.degraded_touch == Some(ev.code) => {
|
||||
self.inject(
|
||||
&InputEvent {
|
||||
kind: InputKind::MouseMoveAbs,
|
||||
..*ev
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
InputKind::TouchUp if self.degraded_touch == Some(ev.code) => {
|
||||
self.degraded_touch = None;
|
||||
self.inject(
|
||||
&InputEvent {
|
||||
kind: InputKind::MouseButtonUp,
|
||||
code: GS_BUTTON_LEFT,
|
||||
..*ev
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate and emit one client input event, committing it as a single `frame`.
|
||||
fn inject(&mut self, ev: &InputEvent, ctx: &ei::Context) {
|
||||
// No ei_touchscreen device but an absolute pointer exists → degrade rather than drop
|
||||
// (the game-mode "touch simply does not work" trap). Checked per event, not latched:
|
||||
// a touchscreen device appearing later (compositor restart, capability change) takes
|
||||
// over seamlessly on the next touch.
|
||||
if matches!(
|
||||
ev.kind,
|
||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp
|
||||
) && self.device_for(DeviceCapability::Touch).is_none()
|
||||
&& self.device_for(DeviceCapability::PointerAbsolute).is_some()
|
||||
{
|
||||
self.degrade_touch(ev, ctx);
|
||||
return;
|
||||
}
|
||||
let cap = match ev.kind {
|
||||
InputKind::MouseMove => DeviceCapability::Pointer,
|
||||
InputKind::MouseMoveAbs => DeviceCapability::PointerAbsolute,
|
||||
@@ -605,16 +722,31 @@ impl EiState {
|
||||
InputKind::MouseMoveAbs => {
|
||||
let w = ((ev.flags >> 16) & 0xffff) as f32;
|
||||
let h = (ev.flags & 0xffff) as f32;
|
||||
match (
|
||||
slot.interface::<ei::PointerAbsolute>(),
|
||||
slot.regions().first(),
|
||||
) {
|
||||
(Some(p), Some(region)) if w > 0.0 && h > 0.0 => {
|
||||
// Map the normalized client position into the device's first region.
|
||||
match slot.interface::<ei::PointerAbsolute>() {
|
||||
Some(p) if w > 0.0 && h > 0.0 => {
|
||||
// Map the normalized client position into the device's first region —
|
||||
// but only when the region looks like a real output geometry.
|
||||
// gamescope's "Gamescope Virtual Input" advertises a degenerate
|
||||
// (0,0,INT32_MAX,INT32_MAX) region meaning "coordinates are raw":
|
||||
// normalizing into it explodes a center tap to x≈1e9, which gamescope
|
||||
// clamps to the far corner (the observed cursor-parked-at-1279,799).
|
||||
// There the managed session runs at the client's mode, so client
|
||||
// pixels ARE output pixels: emit them raw.
|
||||
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
|
||||
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
|
||||
let x = region.x as f32 + nx * region.width as f32;
|
||||
let y = region.y as f32 + ny * region.height as f32;
|
||||
let (x, y) = match slot.regions().first().filter(|r| sane_region(r)) {
|
||||
Some(region) => (
|
||||
region.x as f32 + nx * region.width as f32,
|
||||
region.y as f32 + ny * region.height as f32,
|
||||
),
|
||||
// Degenerate/absent region: scale into the relay-file output hint
|
||||
// (correct even when the client streams at a different resolution
|
||||
// than the session runs); raw client pixels as the last resort.
|
||||
None => match self.output_hint {
|
||||
Some((ow, oh)) => (nx * ow as f32, ny * oh as f32),
|
||||
None => (ev.x as f32, ev.y as f32),
|
||||
},
|
||||
};
|
||||
p.motion_absolute(x, y);
|
||||
}
|
||||
_ => emitted = false,
|
||||
@@ -680,12 +812,21 @@ impl EiState {
|
||||
InputKind::TouchDown | InputKind::TouchMove => {
|
||||
let w = ((ev.flags >> 16) & 0xffff) as f32;
|
||||
let h = (ev.flags & 0xffff) as f32;
|
||||
match (slot.interface::<ei::Touchscreen>(), slot.regions().first()) {
|
||||
(Some(t), Some(region)) if w > 0.0 && h > 0.0 => {
|
||||
match slot.interface::<ei::Touchscreen>() {
|
||||
Some(t) if w > 0.0 && h > 0.0 => {
|
||||
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
|
||||
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
|
||||
let x = region.x as f32 + nx * region.width as f32;
|
||||
let y = region.y as f32 + ny * region.height as f32;
|
||||
// Same degenerate-region fallback ladder as MouseMoveAbs.
|
||||
let (x, y) = match slot.regions().first().filter(|r| sane_region(r)) {
|
||||
Some(region) => (
|
||||
region.x as f32 + nx * region.width as f32,
|
||||
region.y as f32 + ny * region.height as f32,
|
||||
),
|
||||
None => match self.output_hint {
|
||||
Some((ow, oh)) => (nx * ow as f32, ny * oh as f32),
|
||||
None => (ev.x as f32, ev.y as f32),
|
||||
},
|
||||
};
|
||||
if ev.kind == InputKind::TouchDown {
|
||||
t.down(ev.code, x, y);
|
||||
} else {
|
||||
|
||||
@@ -72,8 +72,8 @@ pub use session::{session_epoch, try_recover_session};
|
||||
#[path = "vdisplay/routing.rs"]
|
||||
pub(crate) mod routing;
|
||||
pub use routing::{
|
||||
apply_input_env, restore_managed_session, restore_takeover_on_startup, start_restore_worker,
|
||||
wants_dedicated_game_session,
|
||||
apply_input_env, managed_session_available, restore_managed_session,
|
||||
restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use routing::{
|
||||
|
||||
@@ -1116,6 +1116,26 @@ pub fn schedule_restore_tv_session() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does any DRM connector report a physically `connected` display? Scans
|
||||
/// `/sys/class/drm/*/status` — only connector nodes (`card0-eDP-1`, `card0-HDMI-A-1`, …) have a
|
||||
/// `status` file, so the bare `cardN` device dirs and `renderD*` nodes filter themselves out. A
|
||||
/// headless box (VM, panel-less mini PC) has none — in which case a "restore to the physical
|
||||
/// panel" can only fail, gamescope having no output to drive. Errors (no DRM at all, sysfs
|
||||
/// unreadable) read as headless: the safe direction is keeping the working session.
|
||||
fn physical_display_connected() -> bool {
|
||||
connected_connector_under(std::path::Path::new("/sys/class/drm"))
|
||||
}
|
||||
|
||||
/// [`physical_display_connected`] against an arbitrary sysfs root (the unit-testable core).
|
||||
fn connected_connector_under(base: &std::path::Path) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(base) else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|e| {
|
||||
std::fs::read_to_string(e.path().join("status")).is_ok_and(|s| s.trim() == "connected")
|
||||
})
|
||||
}
|
||||
|
||||
/// Tear down our host-managed session (freeing Steam) and restart the autologin gaming session(s)
|
||||
/// we stopped on connect — so the TV returns to gaming mode when no one is streaming. Invoked by
|
||||
/// [`start_restore_worker`] once the debounce deadline passes; takes the stopped-unit list so a
|
||||
@@ -1127,6 +1147,19 @@ fn do_restore_tv_session() {
|
||||
{
|
||||
let mut took = STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if *took {
|
||||
// A box with no physically connected display (a VM, a panel-less mini PC) has no
|
||||
// "physical gaming session" to restore TO: removing the drop-in and restarting the
|
||||
// target just crash-loops gamescope (no output to drive) and strands every later
|
||||
// connect on "no usable compositor". Keep the headless session — and the takeover
|
||||
// state, so a same-mode reconnect reuses it warm — instead. Checked at restore time
|
||||
// (not connect time) so plugging a panel in later restores normally.
|
||||
if !physical_display_connected() {
|
||||
tracing::info!(
|
||||
"gamescope (SteamOS): no physical display connected — keeping the headless \
|
||||
session (nothing to restore to)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
*took = false;
|
||||
clear_takeover(); // A3: takeover undone — drop the persisted crash-restore marker
|
||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
@@ -1226,15 +1259,31 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
|
||||
/// session). Shared by the attach and host-managed-session paths.
|
||||
fn point_injector_at_eis() {
|
||||
match find_gamescope_eis_socket() {
|
||||
Some(sock) => match std::fs::write(ei_socket_file(), &sock) {
|
||||
Ok(()) => {
|
||||
tracing::info!(socket = %sock, "gamescope: pointed injector at the session's EIS socket")
|
||||
Some(sock) => {
|
||||
// Relay format: line 1 = socket, optional line 2 = the session's CURRENT output
|
||||
// size as "WxH". gamescope's EIS advertises only a degenerate INT32_MAX region, so
|
||||
// the injector can't learn the output geometry from the protocol — the hint lets
|
||||
// it scale normalized client positions correctly even when the client streams at
|
||||
// a different resolution than the session runs (foreign attach, supersample).
|
||||
let size = current_gamescope_output_size();
|
||||
let body = match size {
|
||||
Some((w, h)) => format!("{sock}\n{w}x{h}"),
|
||||
None => sock.clone(),
|
||||
};
|
||||
match std::fs::write(ei_socket_file(), body) {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
socket = %sock,
|
||||
output = ?size,
|
||||
"gamescope: pointed injector at the session's EIS socket"
|
||||
)
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
error = %e,
|
||||
"gamescope: could not write the EIS relay file — input may not reach the session"
|
||||
),
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
error = %e,
|
||||
"gamescope: could not write the EIS relay file — input may not reach the session"
|
||||
),
|
||||
},
|
||||
}
|
||||
None => tracing::warn!(
|
||||
"gamescope: no connectable gamescope EIS socket found — input won't reach the session"
|
||||
),
|
||||
@@ -1524,7 +1573,35 @@ impl Drop for GamescopeProc {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{cgroup_is_punktfunk_owned, is_steam_launch, shape_dedicated_command};
|
||||
use super::{
|
||||
cgroup_is_punktfunk_owned, connected_connector_under, is_steam_launch,
|
||||
shape_dedicated_command,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn connector_status_scan() {
|
||||
let base = std::env::temp_dir().join(format!("pf-drm-scan-{}", std::process::id()));
|
||||
let mk = |name: &str, status: Option<&str>| {
|
||||
let dir = base.join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
if let Some(s) = status {
|
||||
std::fs::write(dir.join("status"), s).unwrap();
|
||||
}
|
||||
};
|
||||
// Headless layout: device + render nodes only (no status files) → not connected.
|
||||
mk("card0", None);
|
||||
mk("renderD128", None);
|
||||
assert!(!connected_connector_under(&base));
|
||||
// Connectors present but nothing plugged in → still not connected.
|
||||
mk("card0-HDMI-A-1", Some("disconnected\n"));
|
||||
assert!(!connected_connector_under(&base));
|
||||
// A live panel → connected.
|
||||
mk("card0-eDP-1", Some("connected\n"));
|
||||
assert!(connected_connector_under(&base));
|
||||
// A missing base dir (no DRM at all) reads as headless.
|
||||
assert!(!connected_connector_under(&base.join("nope")));
|
||||
std::fs::remove_dir_all(&base).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_launch_detection() {
|
||||
|
||||
@@ -207,6 +207,20 @@ pub fn cancel_pending_tv_restore() {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn cancel_pending_tv_restore() {}
|
||||
|
||||
/// Can the MANAGED gamescope path stand a session up from nothing on this box (SteamOS's
|
||||
/// `gamescope-session` launcher or Bazzite's `gamescope-session-plus` present)? Lets the connect
|
||||
/// path route a "no live graphical session" box to the gamescope takeover — which rebuilds the
|
||||
/// session at the client's mode — instead of failing the connect. Always `false` off Linux.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn managed_session_available() -> bool {
|
||||
gamescope::managed_session_available()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn managed_session_available() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Call when a client session ends: if the host-managed gamescope path took over a box's autologin
|
||||
/// gaming session (stopped its single-instance Steam to stream at the client's mode), **schedule** a
|
||||
/// debounced restore so the TV returns to gaming mode — unless a client reconnects within the window
|
||||
|
||||
@@ -861,7 +861,18 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
||||
continue;
|
||||
}
|
||||
if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 {
|
||||
p.flags &= !DISPLAYCONFIG_PATH_ACTIVE; // mark this path inactive
|
||||
// Mark the path inactive AND unpin its modes: per the SetDisplayConfig
|
||||
// contract a path being turned OFF needs BOTH mode indexes marked invalid,
|
||||
// and leaving them referencing the queried mode entries gets the whole
|
||||
// supplied config rejected with 0x57 ERROR_INVALID_PARAMETER on some
|
||||
// driver/topology combinations (field-reported: exclusive mode left the
|
||||
// physical panel lit, every retry failing 0x57). Writing the all-ones
|
||||
// sentinel to the whole union is also correct under the virtual-mode-aware
|
||||
// interpretation (cloneGroupId/sourceModeInfoIdx both become their 0xffff
|
||||
// INVALID values).
|
||||
p.flags &= !DISPLAYCONFIG_PATH_ACTIVE;
|
||||
p.sourceInfo.Anonymous.modeInfoIdx = DISPLAYCONFIG_PATH_MODE_IDX_INVALID;
|
||||
p.targetInfo.Anonymous.modeInfoIdx = DISPLAYCONFIG_PATH_MODE_IDX_INVALID;
|
||||
others += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,10 +86,22 @@ impl NativeClient {
|
||||
// A typed application close from the host (pairing not armed / armed for a
|
||||
// different device / rate-limited / version mismatch) beats the generic
|
||||
// transport error the aborted exchange produced — it is the actual answer.
|
||||
Err(e) => Err(match reject_from_close(&conn) {
|
||||
Some(r) => PunktfunkError::Rejected(r),
|
||||
None => e,
|
||||
}),
|
||||
// Same close-vs-stream-error race as the connect handshake: give the
|
||||
// host's CONNECTION_CLOSE a short grace to be processed before deciding
|
||||
// the error was plain transport trouble.
|
||||
Err(e) => {
|
||||
if conn.close_reason().is_none() {
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(300),
|
||||
conn.closed(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(match reject_from_close(&conn) {
|
||||
Some(r) => PunktfunkError::Rejected(r),
|
||||
None => e,
|
||||
})
|
||||
}
|
||||
ok => ok,
|
||||
};
|
||||
// Always tell the host we're done so it never blocks at its read — code 0 on
|
||||
|
||||
@@ -240,9 +240,22 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
||||
negotiated,
|
||||
host_caps,
|
||||
}),
|
||||
Err(e) => Err(match reject_from_close(&conn) {
|
||||
Some(r) => PunktfunkError::Rejected(r),
|
||||
None => e,
|
||||
}),
|
||||
Err(e) => {
|
||||
// The host's typed close can land a beat AFTER the stream error it caused: the
|
||||
// stream reset/FIN and the CONNECTION_CLOSE are in flight together, and quinn can
|
||||
// hand the reader its mid-frame EOF before it processes the close. Give the close a
|
||||
// short grace to arrive so a host-side setup failure renders as its real reason
|
||||
// ("the host could not start the stream session") instead of "control stream
|
||||
// finished mid-frame". No-op when the connection already closed (or never will —
|
||||
// bounded by the timeout).
|
||||
if conn.close_reason().is_none() {
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_millis(300), conn.closed())
|
||||
.await;
|
||||
}
|
||||
Err(match reject_from_close(&conn) {
|
||||
Some(r) => PunktfunkError::Rejected(r),
|
||||
None => e,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ pub enum PunktfunkStatus {
|
||||
RejectedSuperseded = -26,
|
||||
RejectedWireVersion = -27,
|
||||
RejectedBusy = -28,
|
||||
RejectedSetupFailed = -29,
|
||||
Panic = -99,
|
||||
}
|
||||
|
||||
@@ -89,6 +90,7 @@ impl PunktfunkError {
|
||||
R::Superseded => PunktfunkStatus::RejectedSuperseded,
|
||||
R::WireVersionMismatch => PunktfunkStatus::RejectedWireVersion,
|
||||
R::Busy => PunktfunkStatus::RejectedBusy,
|
||||
R::SetupFailed => PunktfunkStatus::RejectedSetupFailed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ pub const PAIR_APPROVAL_TIMEOUT_CLOSE_CODE: u32 = 0x65;
|
||||
pub const PAIR_SUPERSEDED_CLOSE_CODE: u32 = 0x66;
|
||||
/// The client's wire (protocol) version does not match the host's — one side needs updating.
|
||||
pub const WIRE_VERSION_CLOSE_CODE: u32 = 0x67;
|
||||
/// The host admitted the connection but could not stand the stream session up (compositor /
|
||||
/// capture / encoder setup failed host-side). The close reason bytes carry the specific error
|
||||
/// text for logs/diagnostics; clients render a stable "host-side failure" sentence. Before this
|
||||
/// code, a setup failure reached the client as a bare dropped connection ("control stream
|
||||
/// finished mid-frame") — indistinguishable from transport trouble.
|
||||
pub const SETUP_FAILED_CLOSE_CODE: u32 = 0x68;
|
||||
|
||||
/// Why a host turned a connection away, decoded from the QUIC application close code — the
|
||||
/// client-side view of [`PAIR_NOT_ARMED_CLOSE_CODE`]..[`WIRE_VERSION_CLOSE_CODE`] plus
|
||||
@@ -59,6 +65,9 @@ pub enum RejectReason {
|
||||
WireVersionMismatch,
|
||||
/// The host refused admission because a conflicting session is live.
|
||||
Busy,
|
||||
/// The host admitted the connection but failed to start the stream session (host-side
|
||||
/// setup error — the host log has the specific cause).
|
||||
SetupFailed,
|
||||
}
|
||||
|
||||
impl RejectReason {
|
||||
@@ -75,6 +84,7 @@ impl RejectReason {
|
||||
PAIR_SUPERSEDED_CLOSE_CODE => Self::Superseded,
|
||||
WIRE_VERSION_CLOSE_CODE => Self::WireVersionMismatch,
|
||||
REJECT_BUSY_CLOSE_CODE => Self::Busy,
|
||||
SETUP_FAILED_CLOSE_CODE => Self::SetupFailed,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -91,6 +101,7 @@ impl RejectReason {
|
||||
Self::Superseded => PAIR_SUPERSEDED_CLOSE_CODE,
|
||||
Self::WireVersionMismatch => WIRE_VERSION_CLOSE_CODE,
|
||||
Self::Busy => REJECT_BUSY_CLOSE_CODE,
|
||||
Self::SetupFailed => SETUP_FAILED_CLOSE_CODE,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +118,7 @@ impl RejectReason {
|
||||
Self::Superseded => "superseded",
|
||||
Self::WireVersionMismatch => "wire-version",
|
||||
Self::Busy => "busy",
|
||||
Self::SetupFailed => "setup-failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,6 +137,7 @@ impl std::fmt::Display for RejectReason {
|
||||
Self::Superseded => "a newer request from this device replaced this one",
|
||||
Self::WireVersionMismatch => "client and host versions do not match",
|
||||
Self::Busy => "the host is busy with another session",
|
||||
Self::SetupFailed => "the host could not start the stream session",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -133,7 +146,7 @@ impl std::fmt::Display for RejectReason {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ALL: [RejectReason; 9] = [
|
||||
const ALL: [RejectReason; 10] = [
|
||||
RejectReason::PairingNotArmed,
|
||||
RejectReason::PairingBoundToOtherDevice,
|
||||
RejectReason::PairingRateLimited,
|
||||
@@ -143,6 +156,7 @@ mod tests {
|
||||
RejectReason::Superseded,
|
||||
RejectReason::WireVersionMismatch,
|
||||
RejectReason::Busy,
|
||||
RejectReason::SetupFailed,
|
||||
];
|
||||
|
||||
#[test]
|
||||
@@ -164,7 +178,7 @@ mod tests {
|
||||
fn foreign_codes_stay_untyped() {
|
||||
// Bare closes, the client's own pair-done codes, and the deliberate-end codes must
|
||||
// never read as a host rejection.
|
||||
for code in [0u32, 1, 0x41, 0x51, 0x52, 0x5f, 0x68, u32::MAX] {
|
||||
for code in [0u32, 1, 0x41, 0x51, 0x52, 0x5f, 0x69, u32::MAX] {
|
||||
assert_eq!(RejectReason::from_close_code(code), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,36 @@ pub fn input_test() -> Result<()> {
|
||||
y,
|
||||
flags: 0,
|
||||
};
|
||||
// `PUNKTFUNK_INPUT_TEST_ABS=WxH` (e.g. 1280x800): exercise ABSOLUTE pointer moves instead —
|
||||
// steps through the corners + center of the given surface, 1s apart, so an observer
|
||||
// (`DISPLAY=:0 xdotool getmouselocation`) can verify each jump. This is the degraded-touch
|
||||
// path (touch → MouseMoveAbs), so it validates game-mode touch without a client.
|
||||
if let Ok(dims) = std::env::var("PUNKTFUNK_INPUT_TEST_ABS") {
|
||||
let (w, h) = dims
|
||||
.split_once('x')
|
||||
.and_then(|(w, h)| Some((w.parse::<u32>().ok()?, h.parse::<u32>().ok()?)))
|
||||
.unwrap_or((1280, 800));
|
||||
let flags = (w << 16) | (h & 0xffff);
|
||||
let pts = [
|
||||
(100, 100),
|
||||
(w as i32 - 100, 100),
|
||||
(w as i32 - 100, h as i32 - 100),
|
||||
(100, h as i32 - 100),
|
||||
(w as i32 / 2, h as i32 / 2),
|
||||
];
|
||||
tracing::info!(w, h, "input-test: ABS mode — corners + center, 1s apart");
|
||||
for (x, y) in pts {
|
||||
let mut e = ev(InputKind::MouseMoveAbs, 0, x, y);
|
||||
e.flags = flags;
|
||||
if let Err(err) = inj.inject(&e) {
|
||||
tracing::warn!(error = %format!("{err:#}"), "input-test: abs inject failed");
|
||||
}
|
||||
tracing::info!(x, y, "input-test: abs move emitted");
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
tracing::info!("input-test: done (abs)");
|
||||
return Ok(());
|
||||
}
|
||||
tracing::info!(
|
||||
"input-test: injecting a mouse square + 'A'/click taps for ~8s (watch wev / focused app)"
|
||||
);
|
||||
|
||||
@@ -60,6 +60,9 @@ pub fn start(
|
||||
// Same scheduling posture as the native path's capture/encode thread (Linux nice -10 /
|
||||
// Windows HIGHEST + session tuning) — GameStream previously ran unboosted on Linux.
|
||||
crate::native::boost_thread_priority(true);
|
||||
// A GameStream viewer may be video-only too — hold the suspend/idle inhibitor for
|
||||
// this stream's lifetime (plane parity with the native LiveSessionGuard).
|
||||
let _sleep = crate::sleep_inhibit::hold();
|
||||
tracing::info!(?cfg, "video stream starting");
|
||||
// Lifecycle events + the script-facing marker file, plane parity with the native loop
|
||||
// (RFC §4): `announce` emits `stream.started`/`stream.stopped` and holds the marker for
|
||||
|
||||
@@ -72,6 +72,7 @@ mod send_pacing;
|
||||
mod service;
|
||||
mod session_plan;
|
||||
mod session_status;
|
||||
mod sleep_inhibit;
|
||||
mod spike;
|
||||
mod stats_recorder;
|
||||
// The plugin store: signed catalogs, tiered trust, and install/uninstall jobs that run through the
|
||||
|
||||
@@ -423,6 +423,10 @@ pub(crate) async fn serve(
|
||||
// permit's lifetime: it's released while a knock is parked for delegated approval and
|
||||
// re-acquired on approval, so the hold is no longer a simple closure-scoped binding.
|
||||
let sem_session = sem.clone();
|
||||
// Kept for the error path below: `serve_session` consumes `conn`, but a setup failure
|
||||
// must still close the connection with a typed reason (quinn connections are cheap
|
||||
// Arc-handle clones).
|
||||
let conn_err = conn.clone();
|
||||
sessions.spawn(async move {
|
||||
match serve_session(
|
||||
conn,
|
||||
@@ -445,7 +449,23 @@ pub(crate) async fn serve(
|
||||
"closed before the control handshake (reachability probe)"
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!(%peer, error = %format!("{e:#}"), "session ended with error")
|
||||
// Make the failure legible to the client (the [`close_rejected`] discipline,
|
||||
// extended to EVERY session error): a setup failure that just drops the
|
||||
// connection reaches the client as a bare close mid-control-frame ("control
|
||||
// stream finished mid-frame") — indistinguishable from transport trouble.
|
||||
// Close with the typed setup-failed code, carrying the error text in the
|
||||
// reason bytes for client-side logs. When a gate already closed with its own
|
||||
// typed code, or the peer closed first, this close is a no-op (first wins).
|
||||
let detail = format!("{e:#}");
|
||||
let mut cut = detail.len().min(256);
|
||||
while !detail.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
conn_err.close(
|
||||
punktfunk_core::reject::SETUP_FAILED_CLOSE_CODE.into(),
|
||||
&detail.as_bytes()[..cut],
|
||||
);
|
||||
tracing::warn!(%peer, error = %detail, "session ended with error")
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -92,8 +92,24 @@ pub(super) fn resolve_compositor(
|
||||
let available = crate::vdisplay::available();
|
||||
let chosen = match pick_compositor(pref, &available, detected) {
|
||||
Some(c) => c,
|
||||
// No live session, but the MANAGED gamescope infra exists (SteamOS's
|
||||
// `gamescope-session`, Bazzite's `gamescope-session-plus`): route to the gamescope
|
||||
// backend anyway — its managed path stands the session up from nothing at the
|
||||
// client's mode (drop-in takeover / session relaunch), so a dead gaming session
|
||||
// self-heals on the next connect instead of bouncing every client until someone
|
||||
// restarts it by hand. (The trap that motivated this: a headless SteamOS box whose
|
||||
// gamescope died — every connect failed "no usable compositor" even though the
|
||||
// takeover could rebuild it.) Not under an operator pin: an explicit
|
||||
// `PUNKTFUNK_COMPOSITOR` keeps its exact, hand-configured meaning.
|
||||
None if !overridden && crate::vdisplay::managed_session_available() => {
|
||||
tracing::info!(
|
||||
"no live graphical session — managed gamescope infra present; routing to \
|
||||
the managed takeover to revive the session"
|
||||
);
|
||||
Compositor::Gamescope
|
||||
}
|
||||
None => {
|
||||
// No live session — the state a compositor crash leaves behind (gnome-shell
|
||||
// The state a compositor crash leaves behind (gnome-shell
|
||||
// SIGSEGV → GDM greeter, whose auto-login is once-per-boot). If the operator
|
||||
// configured a recovery hook, fire it (debounced) and tell the client to retry:
|
||||
// its next knock lands in the recovered desktop.
|
||||
|
||||
@@ -158,9 +158,25 @@ pub(crate) fn runner_command() -> Result<(std::path::PathBuf, Vec<String>)> {
|
||||
if bun.exists() && runner.exists() {
|
||||
return Ok((bun, vec![runner.to_string_lossy().into_owned()]));
|
||||
}
|
||||
// Immutable-/usr distros (SteamOS): scripts/steamdeck/install.sh lays the SAME payload
|
||||
// out user-scoped under ~/.local — wrapper, private bun, and bundle mirroring the deb's
|
||||
// /usr layout — because a system package can't exist there.
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let home = std::path::Path::new(&home);
|
||||
let wrapper = home.join(".local/bin/punktfunk-scripting");
|
||||
if wrapper.exists() {
|
||||
return Ok((wrapper, Vec::new()));
|
||||
}
|
||||
let bun = home.join(".local/lib/punktfunk-scripting/bun");
|
||||
let runner = home.join(".local/share/punktfunk-scripting/runner-cli.js");
|
||||
if bun.exists() && runner.exists() {
|
||||
return Ok((bun, vec![runner.to_string_lossy().into_owned()]));
|
||||
}
|
||||
}
|
||||
bail!(
|
||||
"the plugin runner isn't installed — install it first (Debian/Ubuntu: \
|
||||
`sudo apt install punktfunk-scripting`)"
|
||||
`sudo apt install punktfunk-scripting`; SteamOS: re-run \
|
||||
scripts/steamdeck/install.sh)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,12 +114,18 @@ pub fn register(
|
||||
session: session_ref(&session),
|
||||
});
|
||||
registry().lock().unwrap().push(session);
|
||||
LiveSessionGuard { id }
|
||||
LiveSessionGuard {
|
||||
id,
|
||||
_sleep: crate::sleep_inhibit::hold(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes its session from the registry when dropped (any scope exit of the native video loop).
|
||||
pub struct LiveSessionGuard {
|
||||
id: u64,
|
||||
/// While any native session lives, the box must not auto-suspend under a passive viewer
|
||||
/// ([`crate::sleep_inhibit`]) — refcounted, released with the guard.
|
||||
_sleep: crate::sleep_inhibit::StreamHold,
|
||||
}
|
||||
|
||||
impl Drop for LiveSessionGuard {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Session-scoped suspend/idle inhibition: while at least one client is streaming, the host
|
||||
//! holds a logind `sleep:idle` BLOCK inhibitor so the box doesn't auto-suspend out from under a
|
||||
//! passive viewer. Remote INPUT resets the compositor's idle timers, but a video-only viewer
|
||||
//! sends none — observed live on a SteamOS Game-Mode host, which s2idled mid-stream-day and
|
||||
//! dropped off the network (and, in a VM with GPU passthrough, never woke again). Refcounted
|
||||
//! across planes (native sessions + GameStream media): the first hold acquires, the last drop
|
||||
//! releases. Best-effort — no logind (containers, non-systemd boxes) logs once and streams on.
|
||||
//! Off Linux this is a no-op: macOS/Windows hosts manage their own power assertions.
|
||||
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// RAII share of the host-wide inhibitor — hold one per live session/stream.
|
||||
pub struct StreamHold(());
|
||||
|
||||
struct State {
|
||||
count: u32,
|
||||
/// The logind inhibitor pipe fd — inhibition lasts exactly as long as it stays open.
|
||||
#[cfg(target_os = "linux")]
|
||||
fd: Option<ashpd::zbus::zvariant::OwnedFd>,
|
||||
}
|
||||
|
||||
fn state() -> &'static Mutex<State> {
|
||||
static S: OnceLock<Mutex<State>> = OnceLock::new();
|
||||
S.get_or_init(|| {
|
||||
Mutex::new(State {
|
||||
count: 0,
|
||||
#[cfg(target_os = "linux")]
|
||||
fd: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Take a share; the underlying inhibitor is acquired on the 0→1 edge.
|
||||
pub fn hold() -> StreamHold {
|
||||
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
|
||||
st.count += 1;
|
||||
#[cfg(target_os = "linux")]
|
||||
if st.count == 1 && st.fd.is_none() {
|
||||
st.fd = acquire();
|
||||
}
|
||||
StreamHold(())
|
||||
}
|
||||
|
||||
impl Drop for StreamHold {
|
||||
fn drop(&mut self) {
|
||||
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
|
||||
st.count = st.count.saturating_sub(1);
|
||||
#[cfg(target_os = "linux")]
|
||||
if st.count == 0 && st.fd.take().is_some() {
|
||||
tracing::info!("released the sleep/idle inhibitor (no live sessions)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One logind `Inhibit` call on a dedicated plain thread — zbus's blocking API must not run on
|
||||
/// a tokio worker (its internal `block_on` panics there), and callers of [`hold`] may be either.
|
||||
/// The join blocks the caller for the D-Bus round-trip (~ms), which every call site tolerates.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn acquire() -> Option<ashpd::zbus::zvariant::OwnedFd> {
|
||||
let fd = std::thread::spawn(|| -> Option<ashpd::zbus::zvariant::OwnedFd> {
|
||||
use ashpd::zbus;
|
||||
// zbus's blocking API is configured out by ashpd's feature set — drive the async API on
|
||||
// a private current-thread runtime instead (still on this plain thread; see fn doc).
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.ok()?;
|
||||
let attempt: zbus::Result<zbus::zvariant::OwnedFd> = rt.block_on(async {
|
||||
let conn = zbus::Connection::system().await?;
|
||||
let reply = conn
|
||||
.call_method(
|
||||
Some("org.freedesktop.login1"),
|
||||
"/org/freedesktop/login1",
|
||||
Some("org.freedesktop.login1.Manager"),
|
||||
"Inhibit",
|
||||
&("sleep:idle", "Punktfunk", "a client is streaming", "block"),
|
||||
)
|
||||
.await?;
|
||||
reply.body().deserialize()
|
||||
});
|
||||
match attempt {
|
||||
Ok(fd) => Some(fd),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"could not take a logind sleep/idle inhibitor — the box may auto-suspend \
|
||||
under a passive (video-only) viewer"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.join()
|
||||
.ok()
|
||||
.flatten();
|
||||
if fd.is_some() {
|
||||
tracing::info!("holding a logind sleep/idle inhibitor while clients stream");
|
||||
}
|
||||
fd
|
||||
}
|
||||
@@ -70,6 +70,10 @@ These apply to the **Gaming Mode (gamescope)** path only; the desktop path is un
|
||||
- **gamescope 3.16.22 or newer is required.** Older versions can deadlock during capture. Bazzite's
|
||||
and SteamOS's current gamescope is fine; this only bites if you've pinned an old one.
|
||||
- **The mouse cursor isn't included in the captured image** — a gamescope limitation for now.
|
||||
- **Touch arrives as a single-finger pointer.** gamescope's virtual input device has no
|
||||
touchscreen, so the host maps a client's touchscreen to an absolute pointer: taps click exactly
|
||||
where you touch and drags work, but multi-touch gestures (pinch) aren't available in Gaming
|
||||
Mode. The desktop path has full multi-touch.
|
||||
- **HDR isn't supported on the gamescope path** — gamescope's capture output is 8-bit. SDR streams
|
||||
normally.
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@ punktfunk-host plugins add playnite # or: rom-manager
|
||||
punktfunk-host plugins enable # turn the runner on (once)
|
||||
```
|
||||
|
||||
On **SteamOS** the [host installer](/docs/steamos-host) ships the runner automatically (user-scoped
|
||||
under `~/.local` — the read-only `/usr` can't take the package). If the console reports the runner
|
||||
isn't installed on an older setup, re-run `scripts/steamdeck/update.sh` once.
|
||||
|
||||
</Tab>
|
||||
<Tab value="Windows">
|
||||
|
||||
|
||||
@@ -56,14 +56,17 @@ bash ~/punktfunk/scripts/steamdeck/install.sh
|
||||
It is idempotent — safe to re-run. In one pass it:
|
||||
|
||||
1. creates the `pf2` Debian-trixie distrobox and installs the build toolchain,
|
||||
2. builds `punktfunk-host` (and the web console),
|
||||
2. builds `punktfunk-host`, the web console, and the **plugin/script runner** (so the console's
|
||||
[plugin store](/docs/plugins) works out of the box — the runner service itself stays opt-in),
|
||||
3. writes config to `~/.config/punktfunk/` (a generated web-console login password),
|
||||
4. raises the UDP socket buffers to 32 MB, installs the gamepad udev rule + the `vhci-hcd` autoload
|
||||
and adds you to the `input` group (virtual gamepads / **native Steam Deck controller passthrough**),
|
||||
and seeds the KDE RemoteDesktop grant for Desktop-mode input — this step **prompts for your `sudo`
|
||||
seeds the KDE RemoteDesktop grant for Desktop-mode input, and **registers all of it on SteamOS's
|
||||
atomic-update keep list** so OS updates carry it over — this step **prompts for your `sudo`
|
||||
password** (a stock Steam Deck requires one; without it gamepad passthrough and the UDP tuning are skipped),
|
||||
5. installs + starts the `punktfunk-host` and `punktfunk-web` **systemd user services** (with linger,
|
||||
so they run without a login session).
|
||||
so they run without a login session) plus a boot-time **rebuild check** that repairs the host
|
||||
automatically if a SteamOS update ever breaks its library links.
|
||||
|
||||
Useful flags:
|
||||
|
||||
@@ -144,8 +147,13 @@ bash ~/punktfunk/scripts/steamdeck/update.sh
|
||||
degrades to a generic Xbox 360 controller (still fully playable). If you're streaming *to* another
|
||||
Steam Deck, also set Steam Input to **Off** for Punktfunk on that Deck — see
|
||||
[Stream to a Steam Deck](/docs/steam-deck).
|
||||
- **It survives OS updates**, but a major SteamOS bump can move library versions; if the host fails to
|
||||
start after an update, just re-run `update.sh` to rebuild against the new base.
|
||||
- **It survives OS updates — automatically.** SteamOS A/B updates rebuild `/etc` and can move
|
||||
library versions; the installer defends both sides. The system tuning (gamepad udev rule,
|
||||
`vhci-hcd`, UDP buffers) is registered on SteamOS's own atomic-update keep list
|
||||
(`/etc/atomic-update.conf.d/`), so updates carry it over; and a boot-time check
|
||||
(`punktfunk-rebuild-check`) probes the host binary and re-runs the build only if the new OS
|
||||
actually broke its library links — you should never need to intervene. (Re-running `update.sh`
|
||||
by hand still works and is harmless.)
|
||||
- Deeper reference (services, container, manual steps): [`scripts/steamdeck/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/scripts/steamdeck/README.md).
|
||||
|
||||
Trouble? See [Troubleshooting](/docs/troubleshooting) and [Pairing](/docs/pairing).
|
||||
|
||||
@@ -977,6 +977,13 @@
|
||||
// The client's wire (protocol) version does not match the host's — one side needs updating.
|
||||
#define WIRE_VERSION_CLOSE_CODE 103
|
||||
|
||||
// The host admitted the connection but could not stand the stream session up (compositor /
|
||||
// capture / encoder setup failed host-side). The close reason bytes carry the specific error
|
||||
// text for logs/diagnostics; clients render a stable "host-side failure" sentence. Before this
|
||||
// code, a setup failure reached the client as a bare dropped connection ("control stream
|
||||
// finished mid-frame") — indistinguishable from transport trouble.
|
||||
#define SETUP_FAILED_CLOSE_CODE 104
|
||||
|
||||
// Minimum supported multiplier (renders under native, upscaled on present).
|
||||
#define MIN_SCALE 0.5
|
||||
|
||||
@@ -1010,6 +1017,7 @@ enum PunktfunkStatus
|
||||
PUNKTFUNK_STATUS_REJECTED_SUPERSEDED = -26,
|
||||
PUNKTFUNK_STATUS_REJECTED_WIRE_VERSION = -27,
|
||||
PUNKTFUNK_STATUS_REJECTED_BUSY = -28,
|
||||
PUNKTFUNK_STATUS_REJECTED_SETUP_FAILED = -29,
|
||||
PUNKTFUNK_STATUS_PANIC = -99,
|
||||
};
|
||||
#ifndef __cplusplus
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# punktfunk — SteamOS atomic-update preserve list (drop-in for /etc/atomic-update.conf.d/).
|
||||
#
|
||||
# SteamOS's A/B updates rebuild /etc from scratch and carry over ONLY the files matched by
|
||||
# /usr/lib/rauc/atomic-update-keep.conf plus these operator drop-ins (the drop-in dir itself is
|
||||
# on Valve's keep list, so this file is self-preserving). Without it, every OS update silently
|
||||
# strips the host's system integration: /dev/uhid access (virtual pads degrade to Xbox 360),
|
||||
# the vhci-hcd autoload (no native Steam Deck pad), and the UDP buffer sizing (stream stutter).
|
||||
# Same wildcard rules as the stock list: '*' matches within a path segment, '**' across.
|
||||
|
||||
## Virtual-gamepad device access (uinput/uhid/vhci for the input group)
|
||||
/etc/udev/rules.d/60-punktfunk.rules
|
||||
|
||||
## vhci-hcd autoload (usbip transport for the native Steam Deck pad)
|
||||
/etc/modules-load.d/punktfunk.conf
|
||||
|
||||
## UDP socket buffer sizing (32 MB, matches the deb's sysctl drop-in)
|
||||
/etc/sysctl.d/99-punktfunk-net.conf
|
||||
@@ -22,6 +22,15 @@ is only the build environment; `punktfunk-host` is launched directly, not via `d
|
||||
rebuild always matches the running OS. Encode is **VAAPI** on the Deck's AMD GPU (NVENC on NVIDIA),
|
||||
auto-selected by `PUNKTFUNK_ENCODER=auto`.
|
||||
|
||||
The honest trade-off: on-device building costs a slow first install (~10–15 min, ~1 GB of image +
|
||||
toolchain) and adds moving parts (apt mirrors, rustup, bun) — the price of an install that can
|
||||
always chase the OS. Both failure modes of that chase are automated away now:
|
||||
`punktfunk-rebuild-check` rebuilds when an OS update breaks the binary's links, and the
|
||||
atomic-update keep list preserves the `/etc` tuning. The eventual lighter-weight alternative is a
|
||||
CI-prebuilt bundle with the volatile libraries (FFmpeg et al.) vendored under an `$ORIGIN` rpath —
|
||||
OS-update-proof without a toolchain on the device — worth it once SteamOS host volume justifies
|
||||
per-release artifact signing/hosting; the from-source path would stay as the dev/fallback route.
|
||||
|
||||
The web console is the one part that stays in the container at runtime: it's a Nitro **`bun`**
|
||||
build (`bun` both builds **and runs** it — the bun-preset output uses `Bun.serve` with TLS,
|
||||
serving HTTPS (HTTP/1.1 over TLS) with the host's identity cert), so its service does
|
||||
@@ -31,8 +40,9 @@ serving HTTPS (HTTP/1.1 over TLS) with the host's identity cert), so its service
|
||||
|
||||
| Script | What it does |
|
||||
|--------|--------------|
|
||||
| `install.sh` | Idempotent installer: ensure the `pf2` distrobox + toolchain → build host (+web) → write config → tune sysctl + `input` group (sudo) → install + start `punktfunk-host` / `punktfunk-web` systemd **user** services with linger. |
|
||||
| `update.sh` | Rebuild from the current source and restart the services (config + pairings persist). `--pull` does `git pull` first. |
|
||||
| `install.sh` | Idempotent installer: ensure the `pf2` distrobox + toolchain → build host + web + **plugin runner** → write config → tune sysctl + udev + `vhci-hcd` + `input` group and **register it on SteamOS's atomic-update keep list** (sudo) → install + start `punktfunk-host` / `punktfunk-web` systemd **user** services with linger, plus the **rebuild check** below. |
|
||||
| `update.sh` | Rebuild everything from the current source and restart the services (config + pairings persist). `--pull` does `git pull` first. Also retrofits anything a newer install.sh writes (runner, keep-list registration, rebuild check) onto older installs. |
|
||||
| `rebuild-check.sh` | The post-OS-update self-heal (run by `punktfunk-rebuild-check.service` before the host at session start): `ldd`-probes the binary — milliseconds when healthy, a full `update.sh` rebuild only when a SteamOS update actually broke its library links. |
|
||||
|
||||
```sh
|
||||
git clone https://git.unom.io/unom/punktfunk ~/punktfunk
|
||||
@@ -57,10 +67,19 @@ default `pf2`), `PUNKTFUNK_MGMT_PORT` (47990), `PUNKTFUNK_WEB_PORT` (47992).
|
||||
here too and persists across updates.
|
||||
- **Services:** `~/.config/systemd/user/punktfunk-host.service` (runs `serve --gamestream --mgmt-bind
|
||||
0.0.0.0:47990`, `+ --open` if chosen — `--gamestream` adds the Moonlight-compat planes so the Deck's
|
||||
Game Mode also streams to stock Moonlight; the native `punktfunk/1` plane is always on) and
|
||||
`punktfunk-web.service`. Linger is enabled so they run without a login session.
|
||||
Game Mode also streams to stock Moonlight; the native `punktfunk/1` plane is always on),
|
||||
`punktfunk-web.service`, `punktfunk-rebuild-check.service` (post-OS-update self-heal, enabled), and
|
||||
`punktfunk-scripting.service` (plugin runner, **opt-in** — enable it once you use plugins/scripts).
|
||||
Linger is enabled so they run without a login session.
|
||||
- **Plugin runner:** the deb's payload laid out user-scoped (read-only `/usr` can't take the
|
||||
package): wrapper `~/.local/bin/punktfunk-scripting`, pinned `bun` in
|
||||
`~/.local/lib/punktfunk-scripting/`, bundle in `~/.local/share/punktfunk-scripting/`.
|
||||
- **System tuning (sudo):** `/etc/sysctl.d/99-punktfunk-net.conf` (32 MB UDP buffers — the #1
|
||||
high-bitrate lever), `/etc/udev/rules.d/60-punktfunk.rules`, and `$USER` in the `input` group.
|
||||
high-bitrate lever), `/etc/udev/rules.d/60-punktfunk.rules` (`uinput`/`uhid` access),
|
||||
`/etc/modules-load.d/punktfunk.conf` (`vhci-hcd` for the native Deck pad), `$USER` in the `input`
|
||||
group — and `/etc/atomic-update.conf.d/punktfunk.conf`, which registers the three files on
|
||||
SteamOS's atomic-update keep list so A/B OS updates carry them over (verified: without it an
|
||||
update silently strips them — pads degrade to Xbox 360, buffers drop to 208 KB).
|
||||
|
||||
## Operating
|
||||
|
||||
@@ -83,5 +102,7 @@ host advertises over mDNS as `_punktfunk._udp`, so clients discover it automatic
|
||||
for a headless host.
|
||||
- **WiFi tx ceiling** ≈ 250 Mbps goodput (a Deck hardware/driver packet-rate limit, band-independent);
|
||||
fine for 1080p/1440p60. A wired dock lifts it.
|
||||
- **After a major SteamOS update**, if the host won't start, run `update.sh` to rebuild against the new
|
||||
base libraries.
|
||||
- **After a SteamOS update** nothing should be needed: the `/etc` tuning survives via the
|
||||
atomic-update keep list, and `punktfunk-rebuild-check` rebuilds the binary automatically if the
|
||||
new base actually broke its library links (first session start after the update takes the build's
|
||||
few minutes in that case). A manual `update.sh` remains harmless.
|
||||
|
||||
@@ -153,6 +153,35 @@ cd '$SRC/web' && bun install --frozen-lockfile && bun run build
|
||||
ok "web console built"
|
||||
fi
|
||||
|
||||
# --- 2b. plugin runner (scripting) -----------------------------------------
|
||||
# The console's plugin store and `punktfunk-host plugins …` shell out to the scripting runner —
|
||||
# the SDK's runner CLI bundled to one self-contained JS, run on a pinned bun (it import()s the
|
||||
# operator's .ts plugins; see packaging/debian/build-scripting-deb.sh). The .deb lays it out under
|
||||
# /usr, which is read-only here, so ship the SAME payload user-scoped — wrapper + private bun +
|
||||
# bundle under ~/.local, where the host's runner discovery looks after the /usr layouts.
|
||||
log "Building the plugin runner (scripting)"
|
||||
mkdir -p "$HOME/.local/bin" "$HOME/.local/lib/punktfunk-scripting" "$HOME/.local/share/punktfunk-scripting"
|
||||
distrobox enter "$BOX" -- bash -lc "
|
||||
set -e
|
||||
export PATH=\$HOME/.bun/bin:\$PATH
|
||||
cd '$SRC/sdk'
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
bun build src/runner-cli.ts --target=bun --outfile \"\$HOME/.local/share/punktfunk-scripting/runner-cli.js\"
|
||||
# Pin the runtime: copy the box's bun next to the bundle so a bun self-update (or a box rebuild)
|
||||
# never changes what the runner executes under.
|
||||
install -m0755 \"\$(command -v bun)\" \"\$HOME/.local/lib/punktfunk-scripting/bun\"
|
||||
"
|
||||
grep -q 'attempt=' "$HOME/.local/share/punktfunk-scripting/runner-cli.js" \
|
||||
|| die "runner bundle missing the dynamic plugin import — wrong build"
|
||||
cat > "$HOME/.local/bin/punktfunk-scripting" <<'WRAP'
|
||||
#!/bin/sh
|
||||
# Generated by scripts/steamdeck/install.sh — user-scoped punktfunk-scripting (the .deb's /usr/bin
|
||||
# wrapper, relocated): the runner bundle on its private pinned bun.
|
||||
exec "$HOME/.local/lib/punktfunk-scripting/bun" "$HOME/.local/share/punktfunk-scripting/runner-cli.js" "$@"
|
||||
WRAP
|
||||
chmod 0755 "$HOME/.local/bin/punktfunk-scripting"
|
||||
ok "plugin runner: ~/.local/bin/punktfunk-scripting"
|
||||
|
||||
# --- 3. config -------------------------------------------------------------
|
||||
log "Configuration ($CONFIG)"
|
||||
mkdir -p "$CONFIG"
|
||||
@@ -245,6 +274,14 @@ if [ "$SUDO_OK" = 1 ]; then
|
||||
NEED_RELOGIN=1
|
||||
warn "added $USER to the 'input' group (applies on next login)"
|
||||
fi
|
||||
# SteamOS A/B updates rebuild /etc and DROP everything not on Valve's keep list — verified
|
||||
# live: an OS update stripped the udev rule + vhci autoload + UDP sysctl (gamepads silently
|
||||
# degrade to Xbox 360, buffers back to 208 KB). The sanctioned fix is a preserve drop-in in
|
||||
# /etc/atomic-update.conf.d/ (itself on the stock keep list, so it self-preserves).
|
||||
if [ -f "$SRC/scripts/punktfunk-atomic-keep.conf" ]; then
|
||||
sudo install -Dm644 "$SRC/scripts/punktfunk-atomic-keep.conf" /etc/atomic-update.conf.d/punktfunk.conf
|
||||
ok "system tuning registered to survive SteamOS updates (atomic-update.conf.d)"
|
||||
fi
|
||||
else
|
||||
warn "no usable sudo — SKIPPED system tuning. Gamepad passthrough + clean streaming need root (udev"
|
||||
warn "rule, 'input' group, vhci-hcd, UDP buffers) — there is no user-space way to do these."
|
||||
@@ -306,6 +343,35 @@ EOF
|
||||
ok "punktfunk-web.service (port $WEB_PORT)"
|
||||
fi
|
||||
|
||||
# The runner's user unit (OPT-IN, matching the .deb: installed but NOT enabled — the runner is
|
||||
# inert until you add scripts/plugins). ExecStart is rewritten from the packaged /usr wrapper to
|
||||
# the user-scoped one section 2b installed.
|
||||
sed 's|^ExecStart=.*|ExecStart=%h/.local/bin/punktfunk-scripting|' \
|
||||
"$SRC/scripts/punktfunk-scripting.service" > "$UNITS/punktfunk-scripting.service"
|
||||
ok "punktfunk-scripting.service (opt-in: systemctl --user enable --now punktfunk-scripting)"
|
||||
|
||||
# Post-OS-update self-heal: SteamOS A/B updates can bump library sonames the host binary links
|
||||
# (FFmpeg/PipeWire/libva) — this oneshot probes the binary with ldd before punktfunk-host starts
|
||||
# and re-runs update.sh only when it actually stopped loading. Milliseconds on a normal boot.
|
||||
cat > "$UNITS/punktfunk-rebuild-check.service" <<EOF
|
||||
# Generated by scripts/steamdeck/install.sh — rebuild the host if a SteamOS update broke its libs.
|
||||
[Unit]
|
||||
Description=punktfunk SteamOS post-update rebuild check
|
||||
Before=punktfunk-host.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=$SRC/scripts/steamdeck/rebuild-check.sh
|
||||
# A cold-ish rebuild is minutes, not seconds.
|
||||
TimeoutStartSec=1800
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
chmod +x "$SRC/scripts/steamdeck/rebuild-check.sh" 2>/dev/null || true
|
||||
systemctl --user enable punktfunk-rebuild-check.service 2>/dev/null || true
|
||||
ok "punktfunk-rebuild-check.service (auto-rebuild after SteamOS updates)"
|
||||
|
||||
systemctl --user daemon-reload
|
||||
loginctl show-user "$USER" 2>/dev/null | grep -q 'Linger=yes' || { sudo loginctl enable-linger "$USER" 2>/dev/null && ok "enabled linger (services run without login)" || warn "could not enable linger — services stop when you log out (sudo loginctl enable-linger $USER)"; }
|
||||
# enable + restart (not `enable --now`): restart picks up unit-file changes on a re-run, where
|
||||
@@ -327,7 +393,7 @@ echo
|
||||
log "Done — punktfunk host is running on this Steam Deck"
|
||||
echo " • Host status: systemctl --user status punktfunk-host"
|
||||
if [ "$WITH_WEB" = 1 ]; then
|
||||
echo " • Web console: http://${IP:-steamdeck.local}:$WEB_PORT (login: see $CONFIG/web.env)"
|
||||
echo " • Web console: https://${IP:-steamdeck.local}:$WEB_PORT (login: see $CONFIG/web.env)"
|
||||
echo " • Pair a device: open the web console → Devices → arm pairing → enter the PIN on the client"
|
||||
fi
|
||||
if [ "$OPEN" = 1 ]; then
|
||||
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# punktfunk — SteamOS post-OS-update self-heal (runs before punktfunk-host at session start).
|
||||
#
|
||||
# The host binary links SteamOS system libraries (FFmpeg, PipeWire, libva, …). A SteamOS A/B
|
||||
# update that bumps a soname leaves the binary unable to load — a silently dead host until
|
||||
# someone remembers to re-run update.sh. This probe is the reliability backstop:
|
||||
# * healthy binary → exit in milliseconds (every normal boot);
|
||||
# * loader breakage → run scripts/steamdeck/update.sh (rebuild host + web + runner against
|
||||
# the new library tree, restart services). The build container, source, and cargo caches
|
||||
# all live under /home, which SteamOS updates never touch — so the rebuild is warm.
|
||||
#
|
||||
# Root is NOT needed: the /etc system tuning survives updates via the atomic-update keep list
|
||||
# (see punktfunk-atomic-keep.conf); only the binary has to chase the OS libraries.
|
||||
set -euo pipefail
|
||||
|
||||
# systemd user units get a minimal PATH — distrobox commonly lives in ~/.local/bin.
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
SRC="${PUNKTFUNK_SRC:-$HOME/punktfunk}"
|
||||
BIN="$SRC/target-steamos/release/punktfunk-host"
|
||||
|
||||
if [ ! -x "$BIN" ]; then
|
||||
echo "punktfunk-host binary missing at $BIN — running a full rebuild" >&2
|
||||
elif ! ldd "$BIN" 2>/dev/null | grep -q "not found"; then
|
||||
exit 0 # every library resolves — nothing to do
|
||||
else
|
||||
echo "punktfunk-host no longer loads after a SteamOS update — its missing libraries:" >&2
|
||||
ldd "$BIN" 2>/dev/null | grep "not found" >&2 || true
|
||||
echo "rebuilding against the new OS tree (this takes a few minutes; streaming resumes after)" >&2
|
||||
fi
|
||||
|
||||
exec bash "$SRC/scripts/steamdeck/update.sh"
|
||||
@@ -8,6 +8,9 @@
|
||||
set -euo pipefail
|
||||
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
|
||||
ok() { printf '\033[1;32m ok\033[0m %s\n' "$*"; }
|
||||
# warn was USED below but never defined — under `set -e` the first warn call ("command not
|
||||
# found") aborted the whole update before the service restarts.
|
||||
warn() { printf '\033[1;33m !!\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
SRC="${PUNKTFUNK_SRC:-$HOME/punktfunk}"
|
||||
@@ -30,6 +33,48 @@ if [ "$WEB" = 1 ]; then
|
||||
ok "web rebuilt"
|
||||
fi
|
||||
|
||||
# Plugin runner (scripting): rebuild the user-scoped runner payload (install.sh §2b) — also
|
||||
# RETROFITS it onto older installs that predate it (the "plugin runner isn't installed" console
|
||||
# state on SteamOS).
|
||||
log "Rebuilding plugin runner (scripting)"
|
||||
mkdir -p "$HOME/.local/bin" "$HOME/.local/lib/punktfunk-scripting" "$HOME/.local/share/punktfunk-scripting"
|
||||
distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.bun/bin:\$PATH; cd '$SRC/sdk' && bun install --frozen-lockfile --ignore-scripts && bun build src/runner-cli.ts --target=bun --outfile \"\$HOME/.local/share/punktfunk-scripting/runner-cli.js\" && install -m0755 \"\$(command -v bun)\" \"\$HOME/.local/lib/punktfunk-scripting/bun\""
|
||||
grep -q 'attempt=' "$HOME/.local/share/punktfunk-scripting/runner-cli.js" \
|
||||
|| die "runner bundle missing the dynamic plugin import — wrong build"
|
||||
cat > "$HOME/.local/bin/punktfunk-scripting" <<'WRAP'
|
||||
#!/bin/sh
|
||||
# Generated by scripts/steamdeck/update.sh — user-scoped punktfunk-scripting (see install.sh §2b).
|
||||
exec "$HOME/.local/lib/punktfunk-scripting/bun" "$HOME/.local/share/punktfunk-scripting/runner-cli.js" "$@"
|
||||
WRAP
|
||||
chmod 0755 "$HOME/.local/bin/punktfunk-scripting"
|
||||
sed 's|^ExecStart=.*|ExecStart=%h/.local/bin/punktfunk-scripting|' \
|
||||
"$SRC/scripts/punktfunk-scripting.service" > "$HOME/.config/systemd/user/punktfunk-scripting.service"
|
||||
systemctl --user daemon-reload
|
||||
ok "plugin runner rebuilt (opt-in service: systemctl --user enable --now punktfunk-scripting)"
|
||||
|
||||
# Retrofit the post-OS-update rebuild check (install.sh §5) onto older installs: probes the host
|
||||
# binary with ldd at session start and re-runs this script when a SteamOS update broke its links.
|
||||
if [ ! -f "$HOME/.config/systemd/user/punktfunk-rebuild-check.service" ]; then
|
||||
cat > "$HOME/.config/systemd/user/punktfunk-rebuild-check.service" <<EOF
|
||||
# Generated by scripts/steamdeck/update.sh — rebuild the host if a SteamOS update broke its libs.
|
||||
[Unit]
|
||||
Description=punktfunk SteamOS post-update rebuild check
|
||||
Before=punktfunk-host.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=$SRC/scripts/steamdeck/rebuild-check.sh
|
||||
TimeoutStartSec=1800
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
chmod +x "$SRC/scripts/steamdeck/rebuild-check.sh" 2>/dev/null || true
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable punktfunk-rebuild-check.service 2>/dev/null || true
|
||||
ok "punktfunk-rebuild-check.service installed (auto-rebuild after SteamOS updates)"
|
||||
fi
|
||||
|
||||
# Retrofit config that install.sh now writes but older installs predate (both idempotent):
|
||||
# RADV_PERFTEST — Van Gogh RADV still gates VK_KHR_video_encode_* behind it; without it the
|
||||
# Vulkan backend can't open and sessions silently fall back to libav VAAPI. The KWin .desktop —
|
||||
@@ -77,6 +122,13 @@ if [ "$SUDO_OK" = 1 ]; then
|
||||
sudo usermod -aG input "$USER"
|
||||
warn "added $USER to the 'input' group — REBOOT (or log out/in) for it to apply"
|
||||
fi
|
||||
# Register the tuning on Valve's atomic-update preserve list (see install.sh §4): without
|
||||
# this, every SteamOS A/B update strips the three files above again (verified live —
|
||||
# gamepads silently degrade to Xbox 360, UDP buffers back to 208 KB).
|
||||
if [ -f "$SRC/scripts/punktfunk-atomic-keep.conf" ]; then
|
||||
sudo install -Dm644 "$SRC/scripts/punktfunk-atomic-keep.conf" /etc/atomic-update.conf.d/punktfunk.conf
|
||||
ok "system tuning registered to survive SteamOS updates (atomic-update.conf.d)"
|
||||
fi
|
||||
else
|
||||
warn "no usable sudo — SKIPPED gamepad/udev/vhci/UDP tuning (all root-only; no user-space alternative)."
|
||||
warn "A stock SteamOS 'deck' account has NO password — set one with 'passwd', then re-run. Gamepads stay"
|
||||
@@ -94,8 +146,12 @@ if [ ! -s "$GRANT_DST" ] && [ -s "$GRANT_SRC" ]; then
|
||||
fi
|
||||
|
||||
log "Restarting services"
|
||||
systemctl --user restart punktfunk-host.service
|
||||
ok "punktfunk-host restarted"
|
||||
if [ "$WEB" = 1 ]; then systemctl --user restart punktfunk-web.service; ok "punktfunk-web restarted"; fi
|
||||
# --no-block: when this script runs INSIDE punktfunk-rebuild-check.service (ordered
|
||||
# Before=punktfunk-host), a blocking restart would deadlock — the restart job waits for the
|
||||
# check unit, which waits for this script, which waits for the restart. Enqueue and move on;
|
||||
# systemd starts the service the moment the ordering allows.
|
||||
systemctl --user restart --no-block punktfunk-host.service
|
||||
ok "punktfunk-host restart queued"
|
||||
if [ "$WEB" = 1 ]; then systemctl --user restart --no-block punktfunk-web.service; ok "punktfunk-web restart queued"; fi
|
||||
echo
|
||||
log "Updated. Status: systemctl --user status punktfunk-host"
|
||||
|
||||
Reference in New Issue
Block a user