Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e2b956de6 | ||
|
|
49b5ffa2d8 | ||
|
|
d65b9f3b1b | ||
|
|
cd0b53f8fe | ||
|
|
540e282e60 | ||
|
|
e0a822016f | ||
|
|
b6938a9890 | ||
|
|
faf94087c5 | ||
|
|
8c628b4e6c |
@@ -370,9 +370,11 @@ jobs:
|
||||
run: bun run build
|
||||
- name: Typecheck
|
||||
run: bun run lint
|
||||
# Scoped to server/: the console's browser code has no test runner, but the gate that keeps a
|
||||
# plugin's origin apart from the console's does — and its failure mode is a well-formed header
|
||||
# that only a browser rejects, which nothing else here would catch.
|
||||
# Scoped to server/ and nitro-entry/: the console's browser code has no test runner, but two
|
||||
# gates here do — the one keeping a plugin's origin apart from the console's, whose failure
|
||||
# mode is a well-formed header that only a browser rejects, and the one picking which of the
|
||||
# host's two identities the console serves, whose failure mode is a cert no browser accepts.
|
||||
# Neither would be caught anywhere else.
|
||||
- name: Test
|
||||
run: bun run test
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ const SCROLL_HORIZONTAL: u32 = 1;
|
||||
/// `wl_output.name` — the connector name we match the streamed head on — arrived in v4. Nothing
|
||||
/// else we ask of an output needs more than v1, so a lower advert only costs us the names (and
|
||||
/// with them the ability to aim absolute input; see [`index_named`]). Same constant, same reason,
|
||||
/// as `pf_vdisplay`'s `kwin_dpms`.
|
||||
/// as `pf_vdisplay`'s `panel_dpms`.
|
||||
const WL_OUTPUT_MAX: u32 = 4;
|
||||
|
||||
/// One `wl_output` the compositor has advertised.
|
||||
|
||||
@@ -867,14 +867,25 @@ mod kwin;
|
||||
#[path = "vdisplay/linux/kwin_output_mgmt.rs"]
|
||||
mod kwin_output_mgmt;
|
||||
|
||||
// DPMS control of the box's live KDE desktop (org_kde_kwin_dpms) — how a bare-spawn gamescope
|
||||
// session honors `Topology::Exclusive`: the spawn is its own headless compositor, so the desktop's
|
||||
// physical outputs can't be *disabled* (KWin refuses zero enabled outputs and no output there is
|
||||
// ours) — they are put to DPMS-off for the stream instead, refcounted across concurrent spawns.
|
||||
// Consumed by `gamescope` (best-effort, with kscreen fallback).
|
||||
// DPMS control of the box's own physical panels — how a gamescope session (which owns no output on
|
||||
// the box's desktop) honors `Topology::Exclusive`. Dispatches per desktop: KDE over
|
||||
// org_kde_kwin_dpms, sway and Hyprland over their own IPC, and `drm_dpms` for a box with no
|
||||
// desktop at all. GNOME is the one it cannot serve — Mutter exposes no DPMS to clients.
|
||||
// The desktop's outputs can't be *disabled* the way the desktop backends do it (KWin refuses zero
|
||||
// enabled outputs, and no output there is ours to keep), so DPMS-off is the honest translation:
|
||||
// the desk is untouched, the panels just go dark. Refcounted across concurrent spawns; consumed by
|
||||
// `gamescope` on both its owning routes, best-effort throughout.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/kwin_dpms.rs"]
|
||||
mod kwin_dpms;
|
||||
#[path = "vdisplay/linux/panel_dpms.rs"]
|
||||
mod panel_dpms;
|
||||
|
||||
// The compositor-independent half of the same policy: turn the CRTCs off over DRM directly, for a
|
||||
// box with no desktop to ask (Game Mode runs gamescope and no KWin, and is exactly where the
|
||||
// operator's TV is lit by the box itself). Reached from `panel_dpms`'s "not KDE" arm, which is what
|
||||
// owns the refcount and the hold.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/drm_dpms.rs"]
|
||||
mod drm_dpms;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "vdisplay/windows/manager.rs"]
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! Compositor-independent panel darkening over DRM — how a box with **no desktop compositor**
|
||||
//! honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
|
||||
//!
|
||||
//! [`crate::panel_dpms`] asks KWin to turn the panels off, which is the right answer whenever there
|
||||
//! is a KDE desktop to ask. There often isn't. A box sitting in **Game Mode** runs gamescope and no
|
||||
//! KWin at all, so that path declines — and Game Mode is precisely the deployment where the
|
||||
//! operator's TV is lit by the box itself. Measured on the Nobara VM (2026-08-24): after the
|
||||
//! takeover idles the box's gaming session, `card0-HDMI-A-1` sits at `enabled=enabled dpms=On`
|
||||
//! indefinitely. Nothing blanks on its own — when no client holds DRM master the kernel simply
|
||||
//! keeps the CRTC configured, and fbcon owns it.
|
||||
//!
|
||||
//! So ask the kernel directly. The sequence, all of it measured on that box:
|
||||
//!
|
||||
//! 1. `open("/dev/dri/cardN")` — permitted for the ordinary session user, because logind puts a
|
||||
//! **uaccess ACL** on the node for whoever holds the active seat (`crw-rw----+`). No root, no
|
||||
//! polkit, no group: this is the same access every local compositor gets.
|
||||
//! 2. `DRM_IOCTL_SET_MASTER` — succeeds while no one else is master, which is exactly the state the
|
||||
//! takeover has just produced by idling the box's session. If it FAILS, someone else is driving
|
||||
//! that card (a live compositor, a foreign gamescope) and we decline: darkening a panel out from
|
||||
//! under its owner is not ours to do, and on the Attach route it would darken the very picture
|
||||
//! being streamed.
|
||||
//! 3. `DRM_IOCTL_MODE_GETRESOURCES` (count pass, then data pass) for the CRTC ids, and
|
||||
//! `DRM_IOCTL_MODE_SETCRTC` with `fb_id = 0, mode_valid = 0, count_connectors = 0` on each one
|
||||
//! that is actually driving something. That is a modeset to "off": the connector goes
|
||||
//! `enabled=disabled dpms=Off`, which is the same end state `kscreen-doctor --dpms off` reaches
|
||||
//! through KWin.
|
||||
//! 4. `DRM_IOCTL_DROP_MASTER`, and **keep the fd open**.
|
||||
//!
|
||||
//! Step 4 is the part worth reading twice. The darkness **survives dropping master** (measured), so
|
||||
//! we hand mastering rights straight back — the box's own gamescope must be able to take the card
|
||||
//! when the restore relaunches its session, and a host still holding master would starve it. What
|
||||
//! holds the panel dark is the open fd, not the mastership.
|
||||
//!
|
||||
//! **The re-light is `close(fd)`, and that is the whole of it.** The kernel's last-close handling
|
||||
//! restores the console and the panel comes back lit (measured: `enabled=enabled dpms=On` within
|
||||
//! 2 s of the close). There is no saved mode to replay and no restore that can half-fail — which
|
||||
//! also means **crash safety comes free**, the same property [`crate::panel_dpms`] gets from DPMS
|
||||
//! being non-persistent: a host that dies holding this has its fds closed by the kernel, and the
|
||||
//! box lights up. Nothing to journal, nothing to sweep at startup. (Contrast the Windows
|
||||
//! `pnp_disable_monitors` path, which needs a recovery journal precisely because its disable
|
||||
//! survives everything.)
|
||||
//!
|
||||
//! Best-effort throughout, like every other arm of this policy: a box with no `/dev/dri` at all, a
|
||||
//! card whose master is held by someone else, or a card with nothing lit simply contributes
|
||||
//! nothing and the stream proceeds.
|
||||
|
||||
use std::fs::File;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::Path;
|
||||
|
||||
// ---------------------------------------------------------------- the kernel ABI
|
||||
//
|
||||
// `include/uapi/drm/drm.h` and `drm_mode.h`. Hand-declared rather than pulled from a crate: this is
|
||||
// four ioctls and three plain-old-data structs, and the const asserts below pin every layout that
|
||||
// could drift. `_IO('d', nr)` / `_IOWR('d', nr, T)` encoded by hand — the sizes are in the names.
|
||||
|
||||
/// `DRM_IOCTL_SET_MASTER` — `_IO('d', 0x1e)`.
|
||||
const DRM_IOCTL_SET_MASTER: libc::c_ulong = 0x641e;
|
||||
/// `DRM_IOCTL_DROP_MASTER` — `_IO('d', 0x1f)`.
|
||||
const DRM_IOCTL_DROP_MASTER: libc::c_ulong = 0x641f;
|
||||
/// `DRM_IOCTL_MODE_GETRESOURCES` — `_IOWR('d', 0xA0, drm_mode_card_res)`, 64-byte payload.
|
||||
const DRM_IOCTL_MODE_GETRESOURCES: libc::c_ulong = 0xC040_64A0;
|
||||
/// `DRM_IOCTL_MODE_GETCRTC` — `_IOWR('d', 0xA1, drm_mode_crtc)`, 104-byte payload.
|
||||
const DRM_IOCTL_MODE_GETCRTC: libc::c_ulong = 0xC068_64A1;
|
||||
/// `DRM_IOCTL_MODE_SETCRTC` — `_IOWR('d', 0xA2, drm_mode_crtc)`, 104-byte payload.
|
||||
const DRM_IOCTL_MODE_SETCRTC: libc::c_ulong = 0xC068_64A2;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Default)]
|
||||
struct DrmModeCardRes {
|
||||
fb_id_ptr: u64,
|
||||
crtc_id_ptr: u64,
|
||||
connector_id_ptr: u64,
|
||||
encoder_id_ptr: u64,
|
||||
count_fbs: u32,
|
||||
count_crtcs: u32,
|
||||
count_connectors: u32,
|
||||
count_encoders: u32,
|
||||
min_width: u32,
|
||||
max_width: u32,
|
||||
min_height: u32,
|
||||
max_height: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct DrmModeModeinfo {
|
||||
clock: u32,
|
||||
hdisplay: u16,
|
||||
hsync_start: u16,
|
||||
hsync_end: u16,
|
||||
htotal: u16,
|
||||
hskew: u16,
|
||||
vdisplay: u16,
|
||||
vsync_start: u16,
|
||||
vsync_end: u16,
|
||||
vtotal: u16,
|
||||
vscan: u16,
|
||||
vrefresh: u32,
|
||||
flags: u32,
|
||||
type_: u32,
|
||||
name: [u8; 32],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct DrmModeCrtc {
|
||||
set_connectors_ptr: u64,
|
||||
count_connectors: u32,
|
||||
crtc_id: u32,
|
||||
fb_id: u32,
|
||||
x: u32,
|
||||
y: u32,
|
||||
gamma_size: u32,
|
||||
mode_valid: u32,
|
||||
mode: DrmModeModeinfo,
|
||||
}
|
||||
|
||||
// The ioctl numbers above encode their payload size (0x40 = 64, 0x68 = 104). If a struct here ever
|
||||
// disagrees with that, the kernel reads or writes the wrong number of bytes — so pin it at compile
|
||||
// time rather than discovering it as a corrupted modeset on someone's TV.
|
||||
const _: () = assert!(std::mem::size_of::<DrmModeCardRes>() == 0x40);
|
||||
const _: () = assert!(std::mem::size_of::<DrmModeModeinfo>() == 68);
|
||||
const _: () = assert!(std::mem::size_of::<DrmModeCrtc>() == 0x68);
|
||||
|
||||
impl Default for DrmModeCrtc {
|
||||
fn default() -> Self {
|
||||
// SAFETY: both structs are `repr(C)` plain old data — integers and a `[u8; 32]`, no
|
||||
// padding invariants, no pointers that must be valid, and no `Drop`. An all-zero value is
|
||||
// a legal instance, and is exactly what the ioctls want for "no connectors, no mode".
|
||||
unsafe { std::mem::zeroed() }
|
||||
}
|
||||
}
|
||||
|
||||
/// One card we have darkened: the open fd is the hold. Dropping this closes it, and the kernel
|
||||
/// re-lights — see the module docs.
|
||||
pub struct DrmDarken {
|
||||
/// Kept solely for its `Drop`. The panel stays dark exactly as long as these are open.
|
||||
_cards: Vec<File>,
|
||||
/// Which `/dev/dri/cardN` we actually turned something off on — logging only.
|
||||
pub darkened: Vec<String>,
|
||||
}
|
||||
|
||||
/// `ioctl(fd, req, &mut arg)` for the modeset structs, returning the raw `errno` on failure.
|
||||
///
|
||||
/// Split out so each call site is one line and there is exactly one `unsafe` block to justify
|
||||
/// instead of five near-identical ones.
|
||||
fn ioctl<T>(fd: libc::c_int, req: libc::c_ulong, arg: &mut T) -> std::io::Result<()> {
|
||||
// SAFETY: `fd` is an open DRM node owned by the caller for the whole call; `req` is one of the
|
||||
// five `_IO`/`_IOWR` codes declared above, each paired with the `T` its size field names (the
|
||||
// const asserts pin that); and `arg` is a live, uniquely-borrowed, `repr(C)` value of that
|
||||
// exact type, so the kernel's read/write of `size_of::<T>()` bytes stays inside it.
|
||||
let rc = unsafe { libc::ioctl(fd, req, arg as *mut T) };
|
||||
if rc < 0 {
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn every lit CRTC on every DRM card off, and hold them off. `None` when nothing was darkened
|
||||
/// — no cards, none masterable, or none lit — and therefore nothing to restore.
|
||||
pub fn darken() -> Option<DrmDarken> {
|
||||
let mut cards = Vec::new();
|
||||
let mut darkened = Vec::new();
|
||||
for entry in std::fs::read_dir("/dev/dri").ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
// `cardN` only: `renderD*` is the render node (no modesetting at all) and `by-path/` is a
|
||||
// directory of symlinks to the same nodes.
|
||||
if !name.starts_with("card") {
|
||||
continue;
|
||||
}
|
||||
match darken_card(&path) {
|
||||
// Masterable, but nothing on this card was lit. Its fd is dropped here, which is
|
||||
// correct: we changed nothing, so there is nothing to hold.
|
||||
Ok((_, 0)) => {}
|
||||
Ok((card, n)) => {
|
||||
tracing::debug!(card = name, crtcs = n, "DRM: CRTCs off");
|
||||
darkened.push(name.to_string());
|
||||
// ⚠ HOLD THE FD THAT DID THE WORK. Closing it and re-opening does not survive the
|
||||
// round trip: the close is the kernel's LAST close on that device, which restores
|
||||
// the console and re-lights the panel — the fresh fd then holds nothing. Measured
|
||||
// on the Nobara VM 2026-08-24, where exactly that shape reported `darkened
|
||||
// cards: ["card0"]` while the connector sat at `enabled=enabled dpms=On`.
|
||||
cards.push(card);
|
||||
}
|
||||
Err(why) => tracing::debug!(card = name, %why, "DRM: not ours to darken"),
|
||||
}
|
||||
}
|
||||
if darkened.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(DrmDarken {
|
||||
_cards: cards,
|
||||
darkened,
|
||||
})
|
||||
}
|
||||
|
||||
/// Darken one card, returning the open fd **and** how many CRTCs were actually turned off.
|
||||
///
|
||||
/// The fd comes back with the count because the caller MUST keep this exact one to hold the panel
|
||||
/// dark: closing it is the kernel's last close on the device, which restores the console. A card
|
||||
/// that reports 0 can have its fd dropped freely — nothing was changed to undo.
|
||||
fn darken_card(path: &Path) -> std::io::Result<(File, usize)> {
|
||||
let card = File::options().read(true).write(true).open(path)?;
|
||||
let fd = card.as_raw_fd();
|
||||
// Someone else driving this card (a live compositor, a foreign gamescope) ⇒ not ours. This is
|
||||
// also what keeps the Attach route honest without needing to know about it here.
|
||||
ioctl(fd, DRM_IOCTL_SET_MASTER, &mut 0u64)?;
|
||||
|
||||
// Count pass: every pointer NULL, the kernel fills in the counts.
|
||||
let mut res = DrmModeCardRes::default();
|
||||
ioctl(fd, DRM_IOCTL_MODE_GETRESOURCES, &mut res)?;
|
||||
let n = res.count_crtcs as usize;
|
||||
if n == 0 {
|
||||
let _ = ioctl(fd, DRM_IOCTL_DROP_MASTER, &mut 0u64);
|
||||
return Ok((card, 0));
|
||||
}
|
||||
// Data pass: hand back a buffer sized by that count and ask again.
|
||||
let mut ids = vec![0u32; n];
|
||||
let mut res = DrmModeCardRes {
|
||||
crtc_id_ptr: ids.as_mut_ptr() as u64,
|
||||
count_crtcs: n as u32,
|
||||
..Default::default()
|
||||
};
|
||||
ioctl(fd, DRM_IOCTL_MODE_GETRESOURCES, &mut res)?;
|
||||
// The kernel may report FEWER than the count pass promised (a hotplug between the two); it
|
||||
// never reports more than the buffer we sized, so trust the second count.
|
||||
ids.truncate(res.count_crtcs as usize);
|
||||
|
||||
let mut off = 0usize;
|
||||
for id in ids {
|
||||
let mut crtc = DrmModeCrtc {
|
||||
crtc_id: id,
|
||||
..Default::default()
|
||||
};
|
||||
if ioctl(fd, DRM_IOCTL_MODE_GETCRTC, &mut crtc).is_err() {
|
||||
continue;
|
||||
}
|
||||
// Only touch a CRTC that is actually driving a display. Disabling an already-dark one is a
|
||||
// harmless no-op, but counting it would make the log claim a panel went off that never was
|
||||
// on — and that verdict is the whole point of reporting a count at all.
|
||||
if crtc.mode_valid == 0 && crtc.fb_id == 0 {
|
||||
continue;
|
||||
}
|
||||
// The modeset to "off": no framebuffer, no mode, no connectors.
|
||||
let mut disable = DrmModeCrtc {
|
||||
crtc_id: id,
|
||||
..Default::default()
|
||||
};
|
||||
if ioctl(fd, DRM_IOCTL_MODE_SETCRTC, &mut disable).is_ok() {
|
||||
off += 1;
|
||||
}
|
||||
}
|
||||
// Hand mastering back immediately: the darkness does not depend on holding it (measured), and
|
||||
// the box's own gamescope needs to be able to take this card when the restore relaunches its
|
||||
// session. Keeping it would turn a dark panel into a session that cannot start.
|
||||
let _ = ioctl(fd, DRM_IOCTL_DROP_MASTER, &mut 0u64);
|
||||
Ok((card, off))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DrmModeCardRes, DrmModeCrtc, DrmModeModeinfo};
|
||||
|
||||
/// The layouts the ioctl numbers encode. The `const` asserts above already fail the BUILD on
|
||||
/// drift; this restates them as a test so the reason is greppable from a failure, and pins the
|
||||
/// two field offsets the count/data-pass dance actually depends on.
|
||||
#[test]
|
||||
fn the_abi_structs_match_the_ioctl_payload_sizes() {
|
||||
assert_eq!(std::mem::size_of::<DrmModeCardRes>(), 0x40, "_IOWR 0x40");
|
||||
assert_eq!(std::mem::size_of::<DrmModeModeinfo>(), 68);
|
||||
assert_eq!(std::mem::size_of::<DrmModeCrtc>(), 0x68, "_IOWR 0x68");
|
||||
// `crtc_id_ptr` is the second u64 — the field the data pass points at its id buffer. A
|
||||
// reorder here would hand the kernel the framebuffer-id pointer instead.
|
||||
assert_eq!(std::mem::offset_of!(DrmModeCardRes, crtc_id_ptr), 8);
|
||||
assert_eq!(std::mem::offset_of!(DrmModeCardRes, count_crtcs), 36);
|
||||
// `mode` must sit right after the seven u32s, or SETCRTC reads a mode we never wrote.
|
||||
assert_eq!(std::mem::offset_of!(DrmModeCrtc, mode), 36);
|
||||
}
|
||||
|
||||
/// ON GLASS. Darken this box's panels for real and read the verdict back out of sysfs.
|
||||
///
|
||||
/// Run it on a box with a **connected head and no compositor holding the card** — i.e. exactly
|
||||
/// the takeover state this module exists for. On the Nobara VM:
|
||||
///
|
||||
/// ```sh
|
||||
/// # idle the box's gaming session first (what stop_autologin_sessions does), then:
|
||||
/// ./pf_vdisplay-<hash> --ignored --nocapture drm_dpms
|
||||
/// ```
|
||||
///
|
||||
/// Skips itself (rather than failing) when nothing was ours to darken, because that is the
|
||||
/// honest outcome on a dev box with a live desktop — the card is already mastered.
|
||||
#[test]
|
||||
#[ignore = "on glass: needs a connected head and no compositor holding /dev/dri/card*"]
|
||||
fn live_the_panels_go_dark_and_come_back() {
|
||||
fn connectors() -> Vec<(String, String, String)> {
|
||||
let mut v = Vec::new();
|
||||
let Ok(rd) = std::fs::read_dir("/sys/class/drm") else {
|
||||
return v;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let rd = |f: &str| {
|
||||
std::fs::read_to_string(p.join(f))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if rd("status") == "connected" {
|
||||
v.push((
|
||||
e.file_name().to_string_lossy().into_owned(),
|
||||
rd("enabled"),
|
||||
rd("dpms"),
|
||||
));
|
||||
}
|
||||
}
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
let before = connectors();
|
||||
println!("before: {before:?}");
|
||||
assert!(
|
||||
!before.is_empty(),
|
||||
"no connected head — this test needs one to mean anything"
|
||||
);
|
||||
|
||||
let Some(hold) = super::darken() else {
|
||||
println!("nothing was ours to darken (card already mastered?) — skipping");
|
||||
return;
|
||||
};
|
||||
println!("darkened cards: {:?}", hold.darkened);
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
let during = connectors();
|
||||
println!("during: {during:?}");
|
||||
|
||||
drop(hold);
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
let after = connectors();
|
||||
println!("after: {after:?}");
|
||||
|
||||
// The claim: every head that was lit went dark, and every one of them came back.
|
||||
for (name, en, dpms) in &during {
|
||||
assert_eq!(dpms, "Off", "{name} should be DPMS-off while held ({en})");
|
||||
}
|
||||
assert_eq!(
|
||||
after, before,
|
||||
"dropping the hold must restore exactly the state we found"
|
||||
);
|
||||
}
|
||||
|
||||
/// A zeroed `DrmModeCrtc` IS the disable request — that is the only thing `Default` is for
|
||||
/// here, so a change that made it non-zero would silently stop disabling anything.
|
||||
#[test]
|
||||
fn the_default_crtc_is_the_disable_request() {
|
||||
let c = DrmModeCrtc::default();
|
||||
assert_eq!(c.fb_id, 0, "a framebuffer would keep the CRTC lit");
|
||||
assert_eq!(
|
||||
c.mode_valid, 0,
|
||||
"a valid mode would re-modeset, not disable"
|
||||
);
|
||||
assert_eq!(c.count_connectors, 0);
|
||||
assert_eq!(c.set_connectors_ptr, 0);
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ pub struct GamescopeDisplay {
|
||||
/// ran `apply_input_env`); `create` then falls through to the bare spawn, the safe default.
|
||||
route: Option<crate::GamescopeRoute>,
|
||||
/// The topology-restore action the bare-spawn `create` prepared under `Topology::Exclusive` —
|
||||
/// the release of this display's [`crate::kwin_dpms`] darken hold — pending pickup by the
|
||||
/// the release of this display's [`crate::panel_dpms`] darken hold — pending pickup by the
|
||||
/// registry via [`VirtualDisplay::take_topology_restore`], so it runs at the display's
|
||||
/// teardown (§6.1) and never before.
|
||||
pending_restore: Option<Box<dyn FnOnce() + Send>>,
|
||||
@@ -176,6 +176,63 @@ const SWITCH_HONOR_GRACE: Duration = Duration::from_secs(120);
|
||||
/// [`restore_takeover_on_startup`] is what covers a host that died holding one.
|
||||
static IDLE_DROPIN_ARMED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
|
||||
|
||||
/// Whether the MANAGED route currently holds a [`crate::panel_dpms`] darken hold for
|
||||
/// `Topology::Exclusive`.
|
||||
///
|
||||
/// The managed route cannot register its release the way a bare spawn does. A spawn reports
|
||||
/// `DisplayOwnership::Owned`, so `registry::acquire` picks its `take_topology_restore()` up and
|
||||
/// runs it at teardown; managed reports `SessionManaged`, and that function returns for anything
|
||||
/// not `Owned` **above** the pickup — deliberately, because this module owns the managed
|
||||
/// lifecycle instead. So this module owns the release too: [`do_restore_tv_session`], the one
|
||||
/// teardown every managed path funnels through.
|
||||
///
|
||||
/// A plain bool rather than a count because the managed SESSION is what is darkened, not each
|
||||
/// connect: it survives client disconnects (that is the whole point of [`MANAGED_SESSION`]), and a
|
||||
/// same-mode reconnect reuses it warm without a relaunch. Acquiring per connect would ratchet
|
||||
/// `panel_dpms`'s refcount up with no matching releases and pin the panel dark for the host's life.
|
||||
static MANAGED_DARKEN_HELD: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
|
||||
|
||||
/// The 0→1 edge: should this call actually take a `panel_dpms` hold? Pure, and split from
|
||||
/// [`managed_darken_acquire`] so the balance rule is testable without a live compositor — the same
|
||||
/// shape as `panel_dpms::Holds::acquire_edge`, and for the same reason.
|
||||
fn managed_darken_acquire_edge(held: &mut bool, exclusive: bool) -> bool {
|
||||
if !exclusive || *held {
|
||||
return false;
|
||||
}
|
||||
*held = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// The 1→0 edge: should this call actually release one?
|
||||
fn managed_darken_release_edge(held: &mut bool) -> bool {
|
||||
if !*held {
|
||||
return false;
|
||||
}
|
||||
*held = false;
|
||||
true
|
||||
}
|
||||
|
||||
/// Take the managed route's darken hold, once, if `exclusive` and we don't already hold one.
|
||||
fn managed_darken_acquire(exclusive: bool) {
|
||||
let mut held = MANAGED_DARKEN_HELD
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if managed_darken_acquire_edge(&mut held, exclusive) {
|
||||
crate::panel_dpms::acquire_stream_darken();
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop it, if held. Idempotent, because it is called unconditionally from the restore — which is
|
||||
/// exactly what makes it safe to put above every early return there.
|
||||
fn managed_darken_release() {
|
||||
let mut held = MANAGED_DARKEN_HELD
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if managed_darken_release_edge(&mut held) {
|
||||
crate::panel_dpms::release_stream_darken();
|
||||
}
|
||||
}
|
||||
|
||||
/// A pending debounced TV-session restore: the instant [`do_restore_tv_session`] should fire after
|
||||
/// the last client disconnect. A reconnect inside the window clears it (and reuses the still-warm
|
||||
/// managed session), so we never stop+relaunch gamescope per connect — that per-connect teardown is
|
||||
@@ -537,7 +594,7 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
// The DPMS darken-hold release the bare-spawn `create` registered (Exclusive topology
|
||||
// only). The registry stores it on this display's entry and runs it at teardown — which,
|
||||
// for gamescope, is the display's OWN teardown: every spawn is its own group, and the
|
||||
// cross-session ordering lives in `kwin_dpms`'s refcount, not in the group float.
|
||||
// cross-session ordering lives in `panel_dpms`'s refcount, not in the group float.
|
||||
self.pending_restore.take()
|
||||
}
|
||||
|
||||
@@ -583,8 +640,32 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
// also what the ladder's own default arm picks.
|
||||
None => (None, None),
|
||||
};
|
||||
// `Topology::Exclusive` means the operator asked for the box's own screens to go dark for
|
||||
// the stream. Resolved ONCE, above every route's return, so the managed hold below and the
|
||||
// "free the box's session" decision and the bare spawn's darken at the end of this
|
||||
// function can never disagree within a single create.
|
||||
let exclusive = crate::effective_topology() == crate::policy::Topology::Exclusive;
|
||||
if let Some(client) = session_env {
|
||||
return create_managed_session(&client, mode, self.hdr);
|
||||
let out = create_managed_session(&client, mode, self.hdr)?;
|
||||
// Managed is the route that matters most here: it is the recommended one, it gives the
|
||||
// best experience, and it is the ONLY way to serve a client its own virtual output at
|
||||
// its own mode. So `exclusive` has to mean something on it.
|
||||
//
|
||||
// Its takeover idles the box's autologin session, which stops that session DRIVING the
|
||||
// panel — but measured on the Nobara VM (2026-08-24), that alone leaves the connector
|
||||
// at `enabled=enabled dpms=On` indefinitely: with no DRM master the kernel just keeps
|
||||
// the CRTC configured. Turning it off is [`crate::panel_dpms`]'s job, and on a Game Mode
|
||||
// box (no KWin) that lands in its DRM arm — which needs no compositor and no privilege.
|
||||
//
|
||||
// The hold canNOT ride `self.pending_restore` the way the bare spawn's does: this
|
||||
// route reports `DisplayOwnership::SessionManaged`, and `registry::acquire` returns for
|
||||
// anything not `Owned` ABOVE its `take_topology_restore()` pickup, so that hold would
|
||||
// never be released — and a panel dark after every stream is worse than one left lit.
|
||||
// Hence [`managed_darken_acquire`] / [`managed_darken_release`], balanced against
|
||||
// [`do_restore_tv_session`] instead: the one teardown every managed path funnels
|
||||
// through, and the same place the drop-in sweep lives for the same reason.
|
||||
managed_darken_acquire(exclusive);
|
||||
return Ok(out);
|
||||
}
|
||||
// Attach to an already-running gamescope (a foreign / externally-launched session) instead
|
||||
// of spawning our own: capture its node AND inject into its EIS socket.
|
||||
@@ -601,6 +682,10 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
.context("PUNKTFUNK_GAMESCOPE_NODE must be a node id or 'auto'")?
|
||||
};
|
||||
point_injector_at_eis();
|
||||
// ⚠ NO darken hold here either, and this one is policy: attach MIRRORS a gamescope
|
||||
// that may itself be lighting the physical panel, so honoring `exclusive` by
|
||||
// darkening it would darken the very picture being streamed. `exclusive` cannot be
|
||||
// served on this route; the operator's lever is to pick a model that owns a display.
|
||||
tracing::info!(node_id, "gamescope: attaching to existing PipeWire node");
|
||||
// ATTACH = mirror a foreign gamescope we don't own → External (no keep-alive/reuse).
|
||||
return Ok(VirtualOutput {
|
||||
@@ -631,7 +716,8 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
// NO instance free — and then collided with the box's own autologin/desktop Steam, which
|
||||
// is precisely the collision this block exists to prevent.
|
||||
let app = resolved_spawn_app(self.cmd.as_deref());
|
||||
if app.as_deref().is_some_and(is_steam_launch) {
|
||||
let steam = app.as_deref().is_some_and(is_steam_launch);
|
||||
if steam {
|
||||
// A dedicated launch NEEDS Steam's single instance — no attach degrade exists here, so
|
||||
// a mask-fragile-DM box without takeover privilege fails with the actionable error.
|
||||
stop_autologin_sessions()
|
||||
@@ -639,6 +725,24 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
// B1b: a Steam running in a plain DESKTOP session (GNOME/KDE) holds the instance just
|
||||
// the same, and the autologin stop above can't see it — free it too, or fail loudly.
|
||||
free_desktop_steam()?;
|
||||
} else if free_box_session_for_exclusive(steam, exclusive) {
|
||||
// B1c: a NON-Steam launch has no single instance to free, and used to leave the box's
|
||||
// gaming session completely untouched. On a Game Mode box that session IS the DRM
|
||||
// master of the TV (`gamescope/heads.rs`), so under Exclusive it went on lighting the
|
||||
// panel with live Game Mode for the whole stream — the loudest half of the Nobara field
|
||||
// report, and never a 0.31.0 regression: this path has always been Steam-gated.
|
||||
//
|
||||
// Best-effort, unlike the Steam arm above: freeing the session is what MAKES the panel
|
||||
// dark here, not what makes the launch possible, so a box that refuses costs the
|
||||
// operator their dark screen and not their game. The restore is the same machinery
|
||||
// either way (`STOPPED_AUTOLOGIN` → `schedule_restore_tv_session`).
|
||||
if let Err(why) = stop_autologin_sessions() {
|
||||
tracing::warn!(
|
||||
%why,
|
||||
"exclusive topology: could not free the box's gaming session, so its own \
|
||||
display keeps whatever it is showing for this stream"
|
||||
);
|
||||
}
|
||||
}
|
||||
// A5: a per-spawn instance id addresses this spawn's log + node discovery, so two coexisting
|
||||
// bare-spawns (a kept lingering one + a fresh one) never parse each other's node id from a
|
||||
@@ -685,16 +789,19 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
// the physicals outright, but that door is closed here (KWin refuses zero enabled outputs,
|
||||
// and no output on that desktop is ours to leave enabled) — so the desktop's panels go to
|
||||
// DPMS-off instead, best-effort and self-gating (a box with no KDE desktop declines
|
||||
// quietly inside `kwin_dpms`). Placed AFTER the spawn succeeded, so a failed create never
|
||||
// blanks the user's screen. The hold is refcounted in `kwin_dpms` rather than floated
|
||||
// quietly inside `panel_dpms`). Placed AFTER the spawn succeeded, so a failed create never
|
||||
// blanks the user's screen. The hold is refcounted in `panel_dpms` rather than floated
|
||||
// through the registry's group restore, because every gamescope spawn is its own group
|
||||
// (`registry::group_key`) — the float alone would re-light the panel when the FIRST of two
|
||||
// concurrent spawns ends, under the second's still-live stream. Skipped for Managed (its
|
||||
// takeover already stopped the desktop) and Attach (it mirrors a gamescope that may itself
|
||||
// be driving the physical panel) — both returned earlier in this function.
|
||||
if crate::effective_topology() == crate::policy::Topology::Exclusive {
|
||||
crate::kwin_dpms::acquire_stream_darken();
|
||||
self.pending_restore = Some(Box::new(crate::kwin_dpms::release_stream_darken));
|
||||
// concurrent spawns ends, under the second's still-live stream. Managed takes the same
|
||||
// hold at its own return above, through [`managed_darken_acquire`] rather than this field
|
||||
// (its display is not registry-owned, so there is no `take_topology_restore` pickup to
|
||||
// ride). Only Attach still skips, and for a reason that survives: it mirrors a gamescope
|
||||
// that may itself be driving the physical panel, so darkening it would darken the very
|
||||
// picture being streamed.
|
||||
if exclusive {
|
||||
crate::panel_dpms::acquire_stream_darken();
|
||||
self.pending_restore = Some(Box::new(crate::panel_dpms::release_stream_darken));
|
||||
}
|
||||
// Bare SPAWN: we own the nested gamescope process → registry-poolable (keep-alive-able).
|
||||
Ok(VirtualOutput::owned(
|
||||
@@ -3728,6 +3835,17 @@ fn handback_watch(units: &[String]) {
|
||||
/// [`start_restore_worker`] once the debounce deadline passes; takes the stopped-unit list so a
|
||||
/// cancelled+reconnected window keeps the list for a later real restore.
|
||||
fn do_restore_tv_session(verify: bool) {
|
||||
// Give the box its screens back FIRST, above every early return below — including the SteamOS
|
||||
// ones, which is why this sits at the very top rather than beside the drop-in sweep that
|
||||
// follows the same "must not leak past a return" rule. The managed route's `exclusive` darken
|
||||
// has no registry restore to ride (`DisplayOwnership::SessionManaged` returns above
|
||||
// `take_topology_restore`), so this call is its ONLY release — leaking it would leave the
|
||||
// operator's panel dark for the rest of the host's life.
|
||||
//
|
||||
// Safe this early: releasing re-lights, and every path below either hands the box back or
|
||||
// deliberately keeps a headless session on a box with no connected display (nothing lit to
|
||||
// darken there anyway). Idempotent, so the paths that reach the restore twice cost nothing.
|
||||
managed_darken_release();
|
||||
// SteamOS: we reconfigured `gamescope-session.target` headless via a drop-in. Restore = remove
|
||||
// the drop-in + restart the target (back to the physical panel) — unless the user switched to a
|
||||
// desktop session meanwhile, in which case drop the override and leave the desktop alone.
|
||||
@@ -5192,6 +5310,20 @@ fn is_steam_launch(cmd: &str) -> bool {
|
||||
cmd.split_whitespace().next() == Some("steam")
|
||||
}
|
||||
|
||||
/// Should a bare-spawn launch free the box's own gaming session when it is NOT a Steam launch?
|
||||
///
|
||||
/// Two different requirements reach the same call. A **Steam** launch frees it because it must —
|
||||
/// the single instance is not shareable — and that arm fails the create when it can't. **Exclusive
|
||||
/// topology** frees it because the operator asked for the box's screens to go dark, and on a Game
|
||||
/// Mode box that session is the DRM master of the physical panel; that arm is best-effort.
|
||||
///
|
||||
/// Pure so the gate is testable without systemd: the bug it closes was a policy question
|
||||
/// (`is_steam_launch` standing in for "does the box's session need to get out of the way"), not a
|
||||
/// systemd one.
|
||||
fn free_box_session_for_exclusive(steam: bool, exclusive: bool) -> bool {
|
||||
!steam && exclusive
|
||||
}
|
||||
|
||||
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
|
||||
/// (`steam steam://rungameid/<id>`, produced by `library::command_for`) gets `-gamepadui` inserted
|
||||
/// so the nested Steam is Big Picture — the identity gamescope's `--steam` integration is built
|
||||
@@ -5564,8 +5696,9 @@ mod tests {
|
||||
use super::{
|
||||
any_output_size_is, cancel_pending_restore, cgroup_is_punktfunk_owned,
|
||||
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
|
||||
game_hz, gamescope_output_size, hdr_args, idle_dropin_body, idle_dropin_path,
|
||||
install_idle_dropin, is_steam_launch, mask_unit, missing_flags, mode_mismatch,
|
||||
free_box_session_for_exclusive, game_hz, gamescope_output_size, hdr_args, idle_dropin_body,
|
||||
idle_dropin_path, install_idle_dropin, is_steam_launch, managed_darken_acquire_edge,
|
||||
managed_darken_release_edge, mask_unit, missing_flags, mode_mismatch,
|
||||
nested_wrapper_script, our_wsi_layer_dir, parse_listed_units, plan_bind,
|
||||
release_autologin_mask, remove_idle_dropin, script_hardcodes_gamescope, sentinel_advanced,
|
||||
shape_dedicated_command, switch_ends_mask_window, takeover_state_is_live, unmask_unit,
|
||||
@@ -5899,6 +6032,126 @@ mod tests {
|
||||
assert!(!ran.contains("reinstall"), "{ran}");
|
||||
}
|
||||
|
||||
/// ON GLASS. The MANAGED route's hold, driven against the real `panel_dpms`/`drm_dpms` stack —
|
||||
/// the wiring the pure edge test above cannot see. Run it in the takeover state (the box's
|
||||
/// gaming session idled, so nothing holds DRM master), on a box with a connected head:
|
||||
///
|
||||
/// ```sh
|
||||
/// ./pf_vdisplay-<hash> --ignored --nocapture the_managed_hold_darkens_a_real_panel
|
||||
/// ```
|
||||
#[test]
|
||||
#[ignore = "on glass: needs a connected head and no compositor holding /dev/dri/card*"]
|
||||
fn live_the_managed_hold_darkens_a_real_panel() {
|
||||
fn lit() -> Vec<(String, String)> {
|
||||
let mut v = Vec::new();
|
||||
let Ok(rd) = std::fs::read_dir("/sys/class/drm") else {
|
||||
return v;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let f = |n: &str| {
|
||||
std::fs::read_to_string(p.join(n))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if f("status") == "connected" {
|
||||
v.push((e.file_name().to_string_lossy().into_owned(), f("dpms")));
|
||||
}
|
||||
}
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
let before = lit();
|
||||
println!("before: {before:?}");
|
||||
assert!(
|
||||
!before.is_empty(),
|
||||
"needs a connected head to mean anything"
|
||||
);
|
||||
|
||||
super::managed_darken_acquire(true);
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
let during = lit();
|
||||
println!("during: {during:?}");
|
||||
|
||||
// A reconnect must not take a second hold — if it did, the release below would leave the
|
||||
// panel dark. This is the failure the pure test models; here it is against the real
|
||||
// refcount.
|
||||
super::managed_darken_acquire(true);
|
||||
|
||||
super::managed_darken_release();
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
let after = lit();
|
||||
println!("after: {after:?}");
|
||||
|
||||
let went_dark: Vec<&String> = during
|
||||
.iter()
|
||||
.zip(&before)
|
||||
.filter(|((_, now), (_, was))| was == "On" && now == "Off")
|
||||
.map(|((n, _), _)| n)
|
||||
.collect();
|
||||
if went_dark.is_empty() {
|
||||
println!("nothing was ours to darken (card already mastered?) — skipping");
|
||||
return;
|
||||
}
|
||||
// Deliberately "at least one went dark", not "all did": a box can carry a connected head
|
||||
// the live compositor does not manage. The Hyprland VM has a virtio `Virtual-1` beside the
|
||||
// real `HDMI-A-1`, and only the latter is Hyprland's to darken — asserting all of them
|
||||
// would fail on a difference that is not a defect. What must hold is that the mechanism
|
||||
// darkened something real, and that the release put every head back exactly as found.
|
||||
println!("went dark: {went_dark:?}");
|
||||
assert_eq!(after, before, "the release must restore what we found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_managed_darken_hold_is_taken_once_and_released_once() {
|
||||
// The managed SESSION is what gets darkened, not each connect — it outlives client
|
||||
// disconnects and a same-mode reconnect reuses it warm. So a reconnect must NOT take a
|
||||
// second hold: `panel_dpms`'s refcount would ratchet up with no matching release and pin
|
||||
// the operator's panel dark for the rest of the host's life.
|
||||
let mut held = false;
|
||||
assert!(managed_darken_acquire_edge(&mut held, true), "0→1 darkens");
|
||||
assert!(!managed_darken_acquire_edge(&mut held, true), "reconnect");
|
||||
assert!(!managed_darken_acquire_edge(&mut held, true));
|
||||
|
||||
// The restore calls the release unconditionally, above every early return — so it has to
|
||||
// be idempotent, or a path that reaches the restore twice would release a hold it does
|
||||
// not have and drop someone else's.
|
||||
assert!(managed_darken_release_edge(&mut held), "1→0 re-lights");
|
||||
assert!(!managed_darken_release_edge(&mut held), "already released");
|
||||
assert!(!managed_darken_release_edge(&mut held));
|
||||
|
||||
// And it re-arms: a later stream on the same host lifetime darkens again.
|
||||
assert!(managed_darken_acquire_edge(&mut held, true));
|
||||
assert!(managed_darken_release_edge(&mut held));
|
||||
|
||||
// Not exclusive ⇒ never a hold, so the restore's unconditional release stays a no-op.
|
||||
// This is what makes `extend` / `SharedDesktop` ("never blank the real monitors") mean
|
||||
// what they say on the managed route.
|
||||
let mut held = false;
|
||||
assert!(!managed_darken_acquire_edge(&mut held, false));
|
||||
assert!(!held);
|
||||
assert!(!managed_darken_release_edge(&mut held));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_frees_the_box_session_for_a_non_steam_launch_too() {
|
||||
// The bug: `is_steam_launch` was standing in for "does the box's own session need to get
|
||||
// out of the way", and those are two different questions. A non-Steam library game under
|
||||
// `exclusive` left the box's Game Mode gamescope holding DRM master on the TV, so the
|
||||
// operator's screen showed live Game Mode for the whole stream (Nobara, 2026-08-24).
|
||||
assert!(free_box_session_for_exclusive(false, true));
|
||||
// A Steam launch is already handled by the arm above this one — and that arm is the
|
||||
// FAILING one (the single instance is not optional), so this gate must not also fire and
|
||||
// free the session a second time.
|
||||
assert!(!free_box_session_for_exclusive(true, true));
|
||||
// Not exclusive: the operator did not ask for their screens to go dark, so a non-Steam
|
||||
// launch must keep leaving the box's session strictly alone. This is what makes `extend`
|
||||
// and the `SharedDesktop` preset ("never blank the real monitors") mean what they say.
|
||||
assert!(!free_box_session_for_exclusive(false, false));
|
||||
assert!(!free_box_session_for_exclusive(true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dm_plan_idles_any_dm_that_drove_a_live_session() {
|
||||
// A live gaming session behind a DM: idle it, whatever the flavor. Neither of the two
|
||||
|
||||
@@ -539,6 +539,123 @@ fn heads_to_disable(heads: &[crate::monitors::PhysicalMonitor], ours: &str) -> V
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// DPMS every head that is not ours and not a sibling's off (or back on), for a **gamescope**
|
||||
/// session honoring `Topology::Exclusive` — see [`crate::panel_dpms`].
|
||||
///
|
||||
/// Distinct from [`disable_other_heads`], which is what the *Hyprland backend's own* exclusive
|
||||
/// topology does, and deliberately so on this compositor above all: disabling a Hyprland head is
|
||||
/// the operation whose only known undo is re-reading the operator's whole config
|
||||
/// ([`restore_heads`]), dropping every runtime override they set by hand. DPMS is a separate axis
|
||||
/// — this module's own notes record `dispatch dpms on <name>` failing to re-enable a *disabled*
|
||||
/// head for exactly that reason — so off/on round-trips cleanly and touches nothing else.
|
||||
///
|
||||
/// A gamescope spawn owns no Hyprland output, hence the empty `ours`; a concurrent session's
|
||||
/// `HEADLESS-*` is still spared by [`heads_to_disable`]'s `managed` filter.
|
||||
///
|
||||
/// Returns the heads actually changed, so the re-light undoes exactly those.
|
||||
pub(crate) fn dpms_other_heads(on: bool) -> Vec<String> {
|
||||
let Ok(heads) = list_monitors() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut changed = Vec::new();
|
||||
for name in heads_to_disable(&heads, "") {
|
||||
match dpms_one(&name, on) {
|
||||
// Only a head THIS call moved is recorded: one already in the wanted state was left
|
||||
// alone (the dispatcher toggles, so "fixing" it would break it), and reporting it as
|
||||
// changed would have the re-light toggle a head we never darkened.
|
||||
Ok(true) => changed.push(name),
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!(
|
||||
output = %name, error = %format!("{e:#}"),
|
||||
"hyprland: could not DPMS this monitor for `topology: exclusive`"
|
||||
),
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// The DPMS state Hyprland reports for `name` right now — `hyprctl -j monitors all`'s
|
||||
/// `dpmsStatus`. `None` when the monitor is not listed or the field is missing.
|
||||
///
|
||||
/// Measured on 0.55.4: this tracks the hardware exactly (`dpmsStatus:true` ⇔ the connector's sysfs
|
||||
/// `dpms=On`), in both states, and a DPMS-off monitor stays listed. It is the readback
|
||||
/// [`dpms_one`] is built around.
|
||||
fn monitor_dpms(name: &str) -> Option<bool> {
|
||||
let raw = hyprctl(&["-j", "monitors", "all"]).ok()?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
||||
parsed
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|m| m.get("name").and_then(|v| v.as_str()) == Some(name))?
|
||||
.get("dpmsStatus")?
|
||||
.as_bool()
|
||||
}
|
||||
|
||||
/// Put ONE monitor into `want_on`, reporting whether this call actually changed it.
|
||||
///
|
||||
/// ⚠ **The dispatcher is a TOGGLE, not a set** — measured on 0.55.4 (Lua) 2026-08-24, and the
|
||||
/// single most important fact in this function. It ignores the state word entirely:
|
||||
///
|
||||
/// ```text
|
||||
/// On ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> Off <- asked for ON, got OFF
|
||||
/// Off ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> On
|
||||
/// Off ==[ hl.dsp.dpms{state="off", ...} ]==> On <- asked for OFF, got ON
|
||||
/// ```
|
||||
///
|
||||
/// So a blind "off" LIGHTS an already-dark head, and a blind "on" at teardown DARKENS a lit one —
|
||||
/// the operator's screen left off after the stream, which is the failure this whole policy exists
|
||||
/// to avoid. Hence read → act only if it differs → verify. That shape is also correct on a
|
||||
/// config manager where the call really is a set, so it is not conditional on detecting which.
|
||||
///
|
||||
/// The SPELLING differs too. The classic `hyprctl dispatch dpms off <name>` does not work on the
|
||||
/// Lua manager at all: `dispatch` is shorthand for `hl.dispatch(...)`, so the bare words parse as
|
||||
/// a Lua expression and it dies with `')' expected near 'off'`. A hyprlang box (0.56.2 was probed
|
||||
/// as one) wants the classic form. There is no stable probe for which manager is loaded, and
|
||||
/// [`hyprctl_dispatch`] already catches the exit-0 rejections both produce — so try classic, then
|
||||
/// Lua, and report both failures if neither lands.
|
||||
///
|
||||
/// ⚠ **Never omit the monitor name.** `hl.dsp.dpms("on")` answers `ok` and toggles *something*;
|
||||
/// with a name it is at least addressed at the head we mean.
|
||||
fn dpms_one(name: &str, want_on: bool) -> Result<bool> {
|
||||
if monitor_dpms(name) == Some(want_on) {
|
||||
return Ok(false); // already where we want it — toggling would break it
|
||||
}
|
||||
let classic =
|
||||
match hyprctl_dispatch(&["dispatch", "dpms", if want_on { "on" } else { "off" }, name]) {
|
||||
Ok(()) => None,
|
||||
Err(e) => {
|
||||
let lua = lua_dpms_expr(name, want_on);
|
||||
match hyprctl_dispatch(&["dispatch", &lua]) {
|
||||
Ok(()) => None,
|
||||
Err(lua_err) => Some(format!("hyprlang: {e:#}; lua: {lua_err:#}")),
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(why) = classic {
|
||||
bail!("neither dispatch form was accepted for {name} — {why}");
|
||||
}
|
||||
// Verify, because a toggle that fired against a state we misread is worse than one that did
|
||||
// not fire at all.
|
||||
match monitor_dpms(name) {
|
||||
Some(now) if now == want_on => Ok(true),
|
||||
Some(now) => bail!(
|
||||
"hyprland accepted the dpms dispatch for {name} but it is now dpmsStatus={now}, \
|
||||
wanted {want_on} (the dispatcher toggles — the readback disagreed with reality)"
|
||||
),
|
||||
None => bail!("hyprland stopped listing {name} after its dpms dispatch"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Lua-config-manager spelling of a per-monitor DPMS. Pure, so a test pins the shape — the
|
||||
/// quoting is the whole trick, and an unquoted argument is exactly what the classic form gets
|
||||
/// wrong on that manager.
|
||||
fn lua_dpms_expr(name: &str, on: bool) -> String {
|
||||
format!(
|
||||
"hl.dsp.dpms(\"{}\", \"{name}\")",
|
||||
if on { "on" } else { "off" }
|
||||
)
|
||||
}
|
||||
|
||||
/// Disable every non-managed head for an `exclusive` session, returning the ones actually disabled
|
||||
/// (the input to [`restore_heads`]). Best-effort per head: one that refuses costs exclusivity on
|
||||
/// that screen, not the session.
|
||||
@@ -1388,6 +1505,22 @@ fn portal_thread(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The Lua config manager parses a `dispatch` argument as a Lua expression, so the monitor
|
||||
/// name and the state must both be QUOTED — an unquoted `dpms off HDMI-A-1` is what dies with
|
||||
/// `')' expected near 'off'` on 0.55.4. Pinning the shape here because the quoting is the
|
||||
/// entire difference between working and silently doing nothing.
|
||||
#[test]
|
||||
fn the_lua_dpms_expression_quotes_both_arguments() {
|
||||
assert_eq!(
|
||||
lua_dpms_expr("HDMI-A-1", false),
|
||||
r#"hl.dsp.dpms("off", "HDMI-A-1")"#
|
||||
);
|
||||
assert_eq!(lua_dpms_expr("DP-2", true), r#"hl.dsp.dpms("on", "DP-2")"#);
|
||||
// The monitor name is never omitted: the no-name form answers `ok` and TOGGLES on 0.55.4,
|
||||
// which would flip a just-restored head back off.
|
||||
assert!(lua_dpms_expr("DP-2", true).contains("\"DP-2\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_tag_parses_release_and_dev_builds() {
|
||||
assert_eq!(parse_version_tag("v0.55.0"), Some((0, 55, 0)));
|
||||
|
||||
+172
-22
@@ -1,23 +1,54 @@
|
||||
//! DPMS control of the box's live KDE desktop (`org_kde_kwin_dpms`) — how a bare-spawn gamescope
|
||||
//! session honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
|
||||
//! Turning the box's OWN physical panels off — how a gamescope session honors
|
||||
//! [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
|
||||
//!
|
||||
//! A bare spawn is its OWN headless compositor: nothing on that route touches the desktop the box
|
||||
//! is showing, so on a KDE machine the physical panel keeps displaying the (idle) desktop for the
|
||||
//! whole stream — while the same `exclusive` policy on the KWin route turns the physicals off
|
||||
//! outright. The KWin route's mechanism is closed to us here: KWin refuses an output configuration
|
||||
//! with ZERO enabled outputs, and a gamescope session has no KWin output of its own to leave
|
||||
//! enabled. DPMS is the honest translation of `exclusive` for this route — the desktop stays
|
||||
//! exactly where it is (no topology churn, no window re-homing), the panels go dark, and any
|
||||
//! LOCAL input wakes them, which is the right answer for a desktop someone can walk up to.
|
||||
//! Stream input never wakes them: it is injected into the nested gamescope's own EIS socket and
|
||||
//! does not pass through KWin.
|
||||
//! A gamescope session is its own compositor: nothing on either owning route (bare spawn, managed
|
||||
//! takeover) touches the desktop the box is showing, so the physical panel keeps displaying the
|
||||
//! (idle) desktop for the whole stream — while the same `exclusive` policy on a *desktop* backend
|
||||
//! turns the physicals off outright. That backend's mechanism is closed to us here: a compositor
|
||||
//! refuses an output configuration with ZERO enabled outputs, and a gamescope session has no
|
||||
//! output of its own on that desktop to leave enabled.
|
||||
//!
|
||||
//! Driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the same
|
||||
//! stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a separate
|
||||
//! libkscreen/KDED layer that can be wedged while KWin itself answers fine), with a
|
||||
//! `kscreen-doctor --dpms` shell-out fallback. Best-effort everywhere — a box with no Wayland
|
||||
//! session, or a non-KDE desktop, declines quietly and the stream proceeds with the panel lit,
|
||||
//! exactly as before this module existed.
|
||||
//! DPMS is the honest translation. The desk stays exactly where it is — no topology churn, no
|
||||
//! workspace moves, no window re-homing — the panels just go dark, and any LOCAL input wakes them,
|
||||
//! which is the right answer for a desktop someone can walk up to. Stream input never wakes them:
|
||||
//! it is injected into the nested gamescope's own EIS socket and never reaches the desktop.
|
||||
//!
|
||||
//! **There is no cross-compositor DPMS protocol**, so this module is a dispatcher. In order, each
|
||||
//! arm self-gating so a box only pays for the one that answers:
|
||||
//!
|
||||
//! | desktop | mechanism |
|
||||
//! |---|---|
|
||||
//! | KDE / KWin | in-process `org_kde_kwin_dpms`, then a `kscreen-doctor --dpms` shell-out |
|
||||
//! | sway (wlroots) | `swaymsg output <name> dpms off` ([`crate::wlroots::dpms_other_heads`]) |
|
||||
//! | Hyprland | its dpms dispatcher, read-modify-verify ([`crate::hyprland::dpms_other_heads`]) |
|
||||
//! | none at all | [`crate::drm_dpms`] — the CRTCs off over DRM, no compositor needed |
|
||||
//! | GNOME / Mutter | **cannot be served** — see below |
|
||||
//!
|
||||
//! KDE is driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the
|
||||
//! same stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a
|
||||
//! separate libkscreen/KDED layer that can be wedged while KWin itself answers fine). sway and
|
||||
//! Hyprland are driven through their own native IPC, which is how [`crate::wlroots`] and
|
||||
//! [`crate::hyprland`] already drive them — no second layer to be wedged, so no in-process twin
|
||||
//! is warranted.
|
||||
//!
|
||||
//! Neither of those two is as simple as "send the off command", and the Hyprland one especially
|
||||
//! is not: its dpms dispatcher is a **toggle** that ignores the state word (measured on 0.55.4 —
|
||||
//! asking for `on` turned a lit head OFF), and the classic argv does not even parse under its Lua
|
||||
//! config manager. [`crate::hyprland::dpms_other_heads`] carries the full account; the contract
|
||||
//! this module depends on is only that each arm returns **the heads it actually changed**, so the
|
||||
//! re-light moves exactly those and never a head it did not darken.
|
||||
//!
|
||||
//! The DRM arm is not an afterthought: a box sitting in Game Mode runs gamescope and NO desktop
|
||||
//! compositor, and it is *exactly* the deployment whose TV the operator wants dark.
|
||||
//!
|
||||
//! ⚠ **GNOME is the one gap, and it is structural.** Mutter exposes no DPMS to clients at all, and
|
||||
//! its `exclusive` mechanism (an `ApplyMonitorsConfig` that omits the physicals) needs a virtual
|
||||
//! output of its own to keep enabled — which a gamescope session, being its own compositor, does
|
||||
//! not have. The DRM floor cannot cover it either: Mutter holds DRM master, so `SET_MASTER` is
|
||||
//! refused. [`darken`] says so at `warn!` rather than failing silently.
|
||||
//!
|
||||
//! This module owns the refcount and the hold for every arm — see [`Darkened`] for how each is
|
||||
//! undone.
|
||||
//!
|
||||
//! **The hold is refcounted here, NOT floated through the registry's per-group restore.** Every
|
||||
//! gamescope spawn is its own display group (`registry::group_key` — deliberately, they are
|
||||
@@ -433,6 +464,16 @@ enum Darkened {
|
||||
/// The `kscreen-doctor --dpms off` fallback ran (it takes no per-output address, so the
|
||||
/// re-light is the symmetric `--dpms on`).
|
||||
Kscreen,
|
||||
/// sway (wlroots) turned these outputs off — `swaymsg output <name> dpms off`. Addressed by
|
||||
/// connector name, so the re-light undoes exactly the heads we changed and never a sibling's.
|
||||
Sway(Vec<String>),
|
||||
/// Hyprland turned these monitors off — `hyprctl dispatch dpms off <name>`. Same per-name
|
||||
/// discipline as [`Darkened::Sway`], and the same reason.
|
||||
Hyprland(Vec<String>),
|
||||
/// No desktop to ask, so [`crate::drm_dpms`] turned the CRTCs off over DRM directly. The
|
||||
/// re-light is a `drop` — the hold IS a set of open `/dev/dri/cardN` fds, and the kernel
|
||||
/// re-lights on last close. Nothing to replay, and crash-safe for the same reason.
|
||||
Drm(crate::drm_dpms::DrmDarken),
|
||||
}
|
||||
|
||||
/// The host-wide darken hold — refcounted like `sleep_inhibit`: the 0→1 edge darkens, the 1→0
|
||||
@@ -505,9 +546,42 @@ pub fn release_stream_darken() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The non-KDE desktops we can ask, in preference order. Each self-gates on its own IPC being
|
||||
/// reachable — `wlroots::dpms_other_heads` shells out to `swaymsg`, which needs `SWAYSOCK`;
|
||||
/// Hyprland's needs `HYPRLAND_INSTANCE_SIGNATURE` — so a box only ever pays for the one that
|
||||
/// answers, and a box running neither falls straight through.
|
||||
///
|
||||
/// Both address heads BY NAME and report back the ones they actually changed, so the re-light
|
||||
/// undoes exactly those and never a concurrent session's headless output.
|
||||
///
|
||||
/// **GNOME is absent on purpose.** Mutter exposes no DPMS to clients at all, and its `exclusive`
|
||||
/// mechanism (`ApplyMonitorsConfig` omitting the physicals) needs a virtual output of its own to
|
||||
/// keep enabled — which a gamescope spawn, being its own compositor, does not have. There is
|
||||
/// nothing to call; the `warn!` at the end of [`darken`] names it rather than failing silently.
|
||||
fn non_kde_desktop_darken() -> Option<Darkened> {
|
||||
let sway = crate::wlroots::dpms_other_heads(false);
|
||||
if !sway.is_empty() {
|
||||
tracing::info!(
|
||||
outputs = ?sway,
|
||||
"sway: desktop outputs off for the exclusive gamescope stream"
|
||||
);
|
||||
return Some(Darkened::Sway(sway));
|
||||
}
|
||||
let hypr = crate::hyprland::dpms_other_heads(false);
|
||||
if !hypr.is_empty() {
|
||||
tracing::info!(
|
||||
outputs = ?hypr,
|
||||
"hyprland: desktop monitors off for the exclusive gamescope stream"
|
||||
);
|
||||
return Some(Darkened::Hyprland(hypr));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The 0→1 darken: in-process over `org_kde_kwin_dpms` first, `kscreen-doctor --dpms off` as the
|
||||
/// wedged-compositor fallback. `None` = nothing was darkened (no desktop, not KDE, panels already
|
||||
/// off, or every arm declined) — and therefore nothing to restore.
|
||||
/// wedged-compositor fallback, then the other desktops, then DRM. `None` = nothing was darkened
|
||||
/// (no desktop that answers, panels already off, or every arm declined) — and therefore nothing to
|
||||
/// restore.
|
||||
fn darken() -> Option<Darkened> {
|
||||
match Session::open("darken") {
|
||||
Ok(mut s) => {
|
||||
@@ -526,8 +600,49 @@ fn darken() -> Option<Darkened> {
|
||||
}
|
||||
}
|
||||
// Definitive "not KDE" / "no desktop": no fallback can do better (kscreen-doctor drives
|
||||
// the same KDE-only machinery), so decline quietly — already logged by `open`.
|
||||
Err(OpenFailure::NoDpmsGlobal) | Err(OpenFailure::Connect(_)) => None,
|
||||
// the same KDE-only machinery). Declining is still right — but NOT quietly. [`darken`] is
|
||||
// only ever reached because the operator selected `Topology::Exclusive`, so every decline
|
||||
// here is "you asked for your screens off and they stayed on", which is a verdict and not
|
||||
// a routine state. It sat at `debug!` in `open`, and that silence is what made the Nobara
|
||||
// field report (2026-08-24) undiagnosable: no line anywhere named the panel. Same
|
||||
// discipline as [`relight`], which has always said so when it gave up — a lit panel under
|
||||
// `exclusive` deserves the honesty a dark one already got.
|
||||
Err(e @ (OpenFailure::NoDpmsGlobal | OpenFailure::Connect(_))) => {
|
||||
// Not KDE. Try the other desktops we drive, then the compositor-independent floor.
|
||||
// Each arm self-gates on its own IPC being reachable, so the order is just preference
|
||||
// and a box only ever pays for the ones that answer.
|
||||
if let Some(d) = non_kde_desktop_darken() {
|
||||
return Some(d);
|
||||
}
|
||||
match crate::drm_dpms::darken() {
|
||||
Some(d) => {
|
||||
tracing::info!(
|
||||
cards = ?d.darkened,
|
||||
"DRM: the box's own CRTCs are off for the exclusive gamescope stream (no \
|
||||
desktop compositor to ask — a session in Game Mode has none)"
|
||||
);
|
||||
Some(Darkened::Drm(d))
|
||||
}
|
||||
// Nothing on this box was ours to darken: no desktop that answers, and then no
|
||||
// `/dev/dri` card that was ours either — every one already mastered by someone
|
||||
// else (a live compositor, including the gamescope an Attach route is mirroring,
|
||||
// which must NOT be darkened), or nothing lit. Say so: `darken` is only ever
|
||||
// reached because the operator selected `Topology::Exclusive`, so this is "you
|
||||
// asked for your screens off and they stayed on" — a verdict, not a routine
|
||||
// state. It sat at `debug!` in `open`, and that silence is what made the Nobara
|
||||
// field report (2026-08-24) undiagnosable: no line anywhere named the panel.
|
||||
None => {
|
||||
tracing::warn!(
|
||||
%e,
|
||||
"exclusive topology asked for the box's own screens to go dark: no \
|
||||
desktop compositor on this box could be asked (GNOME/Mutter exposes no \
|
||||
DPMS to clients), and no DRM card was ours to turn off either — the \
|
||||
panel stays as it is for this stream"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
// A live session that stopped answering: the standalone tool rides a different stack
|
||||
// (libkscreen/KDED) and may still get through — the same rationale as `kwin.rs`'s
|
||||
// kscreen fallbacks, honest-verdict discipline included.
|
||||
@@ -594,6 +709,41 @@ fn relight(d: Darkened) {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Per-name, so exactly the heads we darkened come back and a sibling's headless output is
|
||||
// never switched on by us. A head the operator unplugged meanwhile just fails its one
|
||||
// command and says so — the others still re-light.
|
||||
Darkened::Sway(outputs) => {
|
||||
let back = crate::wlroots::dpms_other_heads(true);
|
||||
if back.is_empty() {
|
||||
tracing::error!(
|
||||
?outputs,
|
||||
"sway: could NOT re-light the desktop outputs — they stay dark until local \
|
||||
input or `swaymsg output '*' dpms on`"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(outputs = ?back, "sway: desktop outputs back on");
|
||||
}
|
||||
}
|
||||
Darkened::Hyprland(outputs) => {
|
||||
let back = crate::hyprland::dpms_other_heads(true);
|
||||
if back.is_empty() {
|
||||
tracing::error!(
|
||||
?outputs,
|
||||
"hyprland: could NOT re-light the desktop monitors — they stay dark until \
|
||||
local input or `hyprctl dispatch dpms on`"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(outputs = ?back, "hyprland: desktop monitors back on");
|
||||
}
|
||||
}
|
||||
// The one arm that cannot fail: the hold IS the open fds, so dropping it closes them and
|
||||
// the kernel's last-close restores the console. No ioctl to be refused, no saved mode to
|
||||
// replay — which is why this path needs no "could NOT re-light" line of its own.
|
||||
Darkened::Drm(d) => {
|
||||
let cards = d.darkened.clone();
|
||||
drop(d);
|
||||
tracing::info!(?cards, "DRM: the box's own CRTCs released — panel back on");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,6 +491,45 @@ fn disable_argv(name: &str) -> [&str; 3] {
|
||||
["output", name, "disable"]
|
||||
}
|
||||
|
||||
/// The `swaymsg` argv that DPMS-es `name` off or on. Same noun-first shape as [`disable_argv`],
|
||||
/// and a different axis from it: `dpms off` leaves the output enabled and configured (its
|
||||
/// workspaces do not move, no window is re-homed) and merely stops driving the panel.
|
||||
fn dpms_argv(name: &str, on: bool) -> [&str; 4] {
|
||||
["output", name, "dpms", if on { "on" } else { "off" }]
|
||||
}
|
||||
|
||||
/// DPMS every head that is not ours and not a sibling's off (or back on), for a **gamescope**
|
||||
/// session honoring `Topology::Exclusive` — see [`crate::panel_dpms`].
|
||||
///
|
||||
/// Distinct from [`disable_other_heads`], which is what the *wlroots backend's own* exclusive
|
||||
/// topology does. A gamescope spawn is its own compositor and owns no sway output, so there is
|
||||
/// nothing here to promote to "the desk" and nothing to focus — and disabling the operator's
|
||||
/// outputs would move their workspaces around for a stream that is not even on this compositor.
|
||||
/// DPMS is the honest translation: the desk stays exactly as it is, the panels just go dark.
|
||||
///
|
||||
/// Reuses [`heads_to_disable`]'s filter with an empty `ours`, so a concurrent wlroots session's
|
||||
/// `HEADLESS-*` output is spared for the same reason it is there — blanking it would black out
|
||||
/// that client's stream.
|
||||
///
|
||||
/// Returns the heads actually changed, so the re-light can undo exactly those. Best-effort per
|
||||
/// head, like its neighbour: one that refuses costs a lit screen, not the stream.
|
||||
pub(crate) fn dpms_other_heads(on: bool) -> Vec<String> {
|
||||
let Ok(heads) = list_monitors() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut changed = Vec::new();
|
||||
for name in heads_to_disable(&heads, "") {
|
||||
match swaymsg(&dpms_argv(&name, on)) {
|
||||
Ok(_) => changed.push(name),
|
||||
Err(e) => tracing::warn!(
|
||||
output = %name, error = %format!("{e:#}"),
|
||||
"wlroots: could not DPMS this output for `topology: exclusive`"
|
||||
),
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// The `swaymsg` argv that re-enables `name`. sway keeps a disabled output's configuration, so a
|
||||
/// bare `enable` restores the mode/position/scale it had — there is no need to replay the rule the
|
||||
/// way the Hyprland twin's `reload` does.
|
||||
@@ -1060,6 +1099,30 @@ mod tests {
|
||||
assert_eq!(heads_to_disable(&heads, ours), vec!["DP-1", "HDMI-A-1"]);
|
||||
}
|
||||
|
||||
/// `dpms` is a different sway verb from `disable`, and the difference is the whole point of
|
||||
/// the gamescope arm: `disable` moves workspaces and re-homes windows on the operator's desk,
|
||||
/// `dpms off` leaves the desk alone and only stops driving the panel. Four tokens, not three —
|
||||
/// sway spells it `output <name> dpms on|off`.
|
||||
#[test]
|
||||
fn dpms_is_a_separate_verb_from_disable() {
|
||||
assert_eq!(dpms_argv("DP-1", false), ["output", "DP-1", "dpms", "off"]);
|
||||
assert_eq!(dpms_argv("DP-1", true), ["output", "DP-1", "dpms", "on"]);
|
||||
assert_eq!(disable_argv("DP-1"), ["output", "DP-1", "disable"]);
|
||||
}
|
||||
|
||||
/// The gamescope DPMS arm reuses the disable filter with an EMPTY `ours`: a gamescope spawn
|
||||
/// owns no sway output, so nothing of ours needs sparing — but a concurrent wlroots session's
|
||||
/// `HEADLESS-*` still must be, or darkening would black out that client's stream.
|
||||
#[test]
|
||||
fn the_gamescope_dpms_arm_still_spares_a_sibling_headless() {
|
||||
let heads = [
|
||||
head("DP-1", true),
|
||||
head("HEADLESS-1", true),
|
||||
head("DP-3", false),
|
||||
];
|
||||
assert_eq!(heads_to_disable(&heads, ""), vec!["DP-1"]);
|
||||
}
|
||||
|
||||
/// A box with no physical output (the CI/headless posture) has nothing to disable, so no
|
||||
/// restore is prepared and teardown touches nothing.
|
||||
#[test]
|
||||
|
||||
@@ -419,6 +419,29 @@ pub fn serve(
|
||||
// The shared streaming-stats recorder: one handle for the mgmt API, the GameStream encode loop
|
||||
// (via `AppState`), and the native punktfunk/1 loops (passed to `native::serve`).
|
||||
let stats = crate::stats_recorder::StatsRecorder::new(crate::stats_recorder::default_dir());
|
||||
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
|
||||
// and the management API) always exists.
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(None, None, false)
|
||||
.context("native pairing store")?,
|
||||
);
|
||||
// The identity the native QUIC plane and the mgmt API present (the identity split): P-256 on
|
||||
// hosts no native client ever pinned, the legacy RSA cert otherwise — resolved ONCE here so
|
||||
// the two planes cannot race the first-run adoption. See `crate::identity`.
|
||||
//
|
||||
// Resolved BEFORE the legacy GameStream identity below, and that order is load-bearing twice
|
||||
// over. (1) The web console gates its start on `cert.pem` existing and then serves the native
|
||||
// pair sitting next to it (web/nitro-entry/tls-paths.mjs); minting the legacy pair first
|
||||
// leaves a first-run window where the console starts, finds no native pair, and serves the
|
||||
// SAN-less RSA cert no browser accepts — for the rest of that boot. Running first closes that
|
||||
// window: whenever this call WRITES a native pair, it has done so before `cert.pem` appears.
|
||||
// (It does not write one on an upgraded host whose native clients pinned the legacy cert —
|
||||
// there the console correctly falls back to that same legacy pair.) (2) In the degenerate case
|
||||
// (native clients paired, but the cert they pinned is gone from disk) the old order let
|
||||
// `load_or_create` mint a BRAND-NEW cert.pem that `load_or_adopt` then adopted while logging
|
||||
// that it was preserving their pins — stranding them silently. Reading the dir first means
|
||||
// that case reaches the branch written for it.
|
||||
let native_ident = crate::identity::load_or_adopt(&np).context("native host identity")?;
|
||||
#[cfg(feature = "gamestream")]
|
||||
let state = {
|
||||
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
|
||||
@@ -426,20 +449,10 @@ pub fn serve(
|
||||
};
|
||||
#[cfg(not(feature = "gamestream"))]
|
||||
let state = Arc::new(AppState::new(host, stats.clone()));
|
||||
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
|
||||
// and the management API) always exists.
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(None, None, false)
|
||||
.context("native pairing store")?,
|
||||
);
|
||||
// WP13: hand the GameStream planes the grants registry — the nvhttp launch surface and the
|
||||
// ENet control thread resolve a Moonlight fingerprint's mask against the same registry the
|
||||
// native plane enforces (design §8: it keys on fingerprint hex and serves both stores).
|
||||
let _ = state.access.set(np.clone());
|
||||
// The identity the native QUIC plane and the mgmt API present (the identity split): P-256 on
|
||||
// hosts no native client ever pinned, the legacy RSA cert otherwise — resolved ONCE here so
|
||||
// the two planes cannot race the first-run adoption. See `crate::identity`.
|
||||
let native_ident = crate::identity::load_or_adopt(&np).context("native host identity")?;
|
||||
tracing::info!(
|
||||
hostname = %state.host.hostname,
|
||||
uniqueid = %state.host.uniqueid,
|
||||
|
||||
@@ -1091,7 +1091,10 @@ fn spawn_web(cfg: &WebConfig, data: &Path, job: HANDLE) -> Result<Child> {
|
||||
// The /api proxy hop to the host's loopback HTTPS mgmt API. The host's self-signed cert is
|
||||
// accepted only inside the proxy code (per-request TLS), never process-wide.
|
||||
("PUNKTFUNK_MGMT_URL", mgmt_url),
|
||||
// Serve HTTPS with the host's own identity cert; mark the session cookie Secure.
|
||||
// Serve HTTPS with the host's own identity cert; mark the session cookie Secure. Names the
|
||||
// LEGACY pair — the console prefers the native sibling when it exists
|
||||
// (web/nitro-entry/tls-paths.mjs), which is also what the gate above ends up waiting for:
|
||||
// `serve` resolves the native identity before minting this one.
|
||||
(
|
||||
"PUNKTFUNK_UI_TLS_CERT",
|
||||
data.join("cert.pem").to_string_lossy().into_owned(),
|
||||
|
||||
@@ -208,7 +208,24 @@ fn poll_loop(
|
||||
// that proves the server is answering, and the agent below refuses redirects so the probe is
|
||||
// exactly one round trip. (A 302 still counts as up via the `Status` arm in `probe_console`.)
|
||||
let console_url = format!("https://127.0.0.1:{web_port}/login");
|
||||
let agent = agent(load_pin());
|
||||
// Named, not `agent`: shadowing the fn (as this did while there was only one agent) would make
|
||||
// the second call below resolve to this binding instead.
|
||||
let mgmt_agent = agent(load_pin());
|
||||
// The console probe gets its OWN, UNPINNED agent. It is a different server from the mgmt API
|
||||
// and there is no rule that it presents the same certificate: it served the legacy `cert.pem`
|
||||
// while mgmt served the native one (the identity split), so the pinned agent refused the
|
||||
// handshake and every identity-split host showed "Open web console (not responding)" over a
|
||||
// perfectly healthy console — next to a tooltip reading "idle", because the same agent reached
|
||||
// mgmt fine (field report 2026-08-24). An operator fronting the console with their own LAN-CA
|
||||
// cert would have hit it just as squarely, so the coupling goes rather than the symptom.
|
||||
//
|
||||
// Nothing is lost by dropping the pin: this probe sends no credentials, reads no body, and
|
||||
// decides only presentation — the menu entry's label, plus whether a tray-icon click opens
|
||||
// the console or the menu (win.rs). A port-squatter could flip that, but the entry itself is
|
||||
// unconditional and opens the same URL either way, and no browser ever pinned this cert. On
|
||||
// Windows the probe was never pinned to begin with: `punktfunk_config_dir` returns None there,
|
||||
// so `load_pin` was already None.
|
||||
let console_agent = agent(None);
|
||||
let mut last: Option<(TrayStatus, bool)> = None;
|
||||
// When the summary became unreachable while the service was running (grace anchor).
|
||||
// Runs for the process lifetime (the tray exits by process exit; nothing to unwind).
|
||||
@@ -220,7 +237,7 @@ fn poll_loop(
|
||||
loop {
|
||||
let svc = probe_service();
|
||||
let summary = if svc == ServiceState::Running {
|
||||
let s = fetch_summary(&agent, &summary_url());
|
||||
let s = fetch_summary(&mgmt_agent, &summary_url());
|
||||
match s {
|
||||
Some(_) => unreachable_since = None,
|
||||
None if unreachable_since.is_none() => unreachable_since = Some(Instant::now()),
|
||||
@@ -233,7 +250,7 @@ fn poll_loop(
|
||||
};
|
||||
let grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE);
|
||||
let status = map_status(&svc, summary, grace_expired);
|
||||
let console_up = if probe_console(&agent, &console_url) {
|
||||
let console_up = if probe_console(&console_agent, &console_url) {
|
||||
console_misses = 0;
|
||||
true
|
||||
} else {
|
||||
|
||||
@@ -136,7 +136,24 @@ PUNKTFUNK_CAPTURE_MONITOR=HDMI-A-1
|
||||
|
||||
The host then attaches to the session's own composited output: nothing is stopped, nothing is
|
||||
relaunched, no mode is imposed, and what you see is exactly what is on the TV. That is the
|
||||
difference from **managed**, which deliberately takes the session over and blanks the panel.
|
||||
difference from **managed**, which deliberately takes the session over and relaunches it headless,
|
||||
so the box's own session stops driving that panel.
|
||||
|
||||
Whether the panel then goes *dark* is the **Topology** setting's job, not the model's — see
|
||||
[Virtual displays](/docs/virtual-displays#topology). What `exclusive` can do differs by model:
|
||||
|
||||
- **Managed** and **bare spawn** — the box's own gaming session is moved out of the way so it
|
||||
stops driving the panel, and then the panel is actually turned **off** for the stream and
|
||||
restored at teardown. On a KDE desktop that goes through KWin's DPMS; on a box already in Game
|
||||
Mode there is no KWin to ask, so the host turns the CRTCs off over DRM itself. Neither needs
|
||||
root — the DRM path rides the same seat access every local compositor gets.
|
||||
- **Attach** — nothing is darkened, and cannot be: this model streams the panel the box is
|
||||
driving, so turning it off would turn off the picture.
|
||||
|
||||
Under `extend` or `primary` none of this happens and your screens are left alone. If `exclusive`
|
||||
asked for a dark screen and the host could not deliver one — a box already in Game Mode has no
|
||||
KDE desktop to ask for DPMS — it says so in the log rather than leaving you guessing at a lit
|
||||
screen.
|
||||
|
||||
Only the one head the session drives is listed — a nested or headless gamescope (including the
|
||||
per-session ones the host spawns itself) has none of its own, so the picker is empty there. Full
|
||||
|
||||
@@ -667,6 +667,11 @@ in
|
||||
HOST = "0.0.0.0";
|
||||
# Serve HTTPS with the host's own identity cert (the anchor native clients already pin) and
|
||||
# mark the session cookie Secure. The host's `serve` writes these PEMs.
|
||||
#
|
||||
# These name the LEGACY pair; the server prefers the native sibling
|
||||
# (native-cert.pem/native-key.pem) when it exists, because a generated unit cannot express
|
||||
# "this file, else that one" any more than the hand-written one can. The choice is made in
|
||||
# web/nitro-entry/tls-paths.mjs — keep this in step with scripts/punktfunk-web.service.
|
||||
PUNKTFUNK_UI_TLS_CERT = "%h/.config/punktfunk/cert.pem";
|
||||
PUNKTFUNK_UI_TLS_KEY = "%h/.config/punktfunk/key.pem";
|
||||
PUNKTFUNK_UI_SECURE = "1";
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
#
|
||||
# Installed by the punktfunk-web .deb to /usr/lib/systemd/user/. AUTO-WIRED — no env editing:
|
||||
# it sources the host's mgmt token + the generated login password, serves HTTPS (HTTP/1.1 over TLS)
|
||||
# with the host's own identity cert (~/.config/punktfunk/{cert,key}.pem), and points the /api proxy
|
||||
# with the host's own identity cert (~/.config/punktfunk/native-{cert,key}.pem, falling back to the
|
||||
# legacy {cert,key}.pem — see the PUNKTFUNK_UI_TLS_CERT note below), and points the /api proxy
|
||||
# at the host's loopback HTTPS mgmt API. The self-signed cert is accepted only for that loopback hop,
|
||||
# scoped inside the proxy code (Bun per-request TLS) — no process-wide NODE_TLS_REJECT_UNAUTHORIZED.
|
||||
# Enable per user:
|
||||
@@ -39,6 +40,12 @@ Environment=HOST=0.0.0.0
|
||||
# Serve HTTPS (HTTP/1.1 over TLS) with the host's own identity cert; mark the
|
||||
# session cookie Secure. The host's `serve` writes these PEMs; if absent at start the unit fails and
|
||||
# Restart retries (same as the mgmt-token wait above) rather than silently serving plain HTTP.
|
||||
#
|
||||
# These name the LEGACY pair and the server prefers the native sibling
|
||||
# (native-cert.pem/native-key.pem) whenever it exists — `Environment=` cannot express "this file,
|
||||
# else that one", so the choice is made in web/nitro-entry/tls-paths.mjs, which is the one place
|
||||
# every launcher routes through. Don't "fix" these to the native names: a host that never took the
|
||||
# identity split has no native pair, and the fallback lives on the other side of this handoff.
|
||||
Environment=PUNKTFUNK_UI_TLS_CERT=%h/.config/punktfunk/cert.pem
|
||||
Environment=PUNKTFUNK_UI_TLS_KEY=%h/.config/punktfunk/key.pem
|
||||
Environment=PUNKTFUNK_UI_SECURE=1
|
||||
|
||||
@@ -58,6 +58,8 @@ PORT=47992 HOST=0.0.0.0 \
|
||||
PUNKTFUNK_UI_TLS_KEY=~/.config/punktfunk/key.pem PUNKTFUNK_UI_SECURE=1 \
|
||||
bun run start # = bun run .output/server/index.mjs
|
||||
# PUNKTFUNK_UI_TLS_* unset ⇒ plain HTTP (local dev); both set ⇒ HTTPS (HTTP/1.1 over TLS).
|
||||
# Naming cert.pem/key.pem serves native-cert.pem/native-key.pem instead when both sit beside them
|
||||
# (the identity split — nitro-entry/tls-paths.mjs); the legacy pair is the fallback, not the target.
|
||||
# The host's self-signed mgmt cert is accepted only for the proxy's loopback hop, scoped in code
|
||||
# (Bun per-request TLS: server/routes/api/[...].ts) — no process-wide NODE_TLS_REJECT_UNAUTHORIZED.
|
||||
# See .env.example.
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
//
|
||||
// NOTE on HTTP/2 + HTTP/3: NOT offered here, on purpose. `Bun.serve` has no HTTP/2 server, and
|
||||
// HTTP/3 (which Bun *can* do) is useless to a browser against this cert: QUIC refuses any cert error,
|
||||
// and the host identity cert is a CN-only, no-SAN, self-signed cert (correct for native fingerprint
|
||||
// PINNING, rejected by browsers). So browsers stay on HTTP/1.1 regardless — advertising h3 would just
|
||||
// and the host identity is SELF-SIGNED whichever pair we serve — the native one carries real SANs, so
|
||||
// a browser gets past the name check, but never past the untrusted issuer (and the legacy fallback is
|
||||
// CN-only with no SAN, which fails both). So browsers stay on HTTP/1.1 regardless — advertising h3 would just
|
||||
// dangle an `Alt-Svc` no browser can use. Real h2/h3 would need a browser-TRUSTED, SAN-matching cert
|
||||
// (a local CA installed per device) fronted by a server that speaks them (e.g. Caddy) — deliberately
|
||||
// out of scope for a LAN console; TLS (no cleartext login/session) is the win.
|
||||
@@ -17,14 +18,16 @@
|
||||
// TWO LISTENERS, on purpose — see `PLUGIN ORIGIN` below.
|
||||
//
|
||||
// Env (set by the launchers / the systemd unit — see web.env.example):
|
||||
// PUNKTFUNK_UI_TLS_CERT / _KEY PEM file paths (the host's cert.pem / key.pem). BOTH set ⇒ HTTPS.
|
||||
// Unset ⇒ plain HTTP (local dev only).
|
||||
// PUNKTFUNK_UI_TLS_CERT / _KEY PEM file paths (the host's cert.pem / key.pem — the native
|
||||
// sibling pair is preferred when present, see tls-paths.mjs).
|
||||
// BOTH set ⇒ HTTPS. Unset ⇒ plain HTTP (local dev only).
|
||||
// PORT / HOST standard Nitro bind (3000 / 0.0.0.0).
|
||||
// PUNKTFUNK_UI_PLUGIN_PORT the plugin-UI origin's port (default: console port + 1).
|
||||
import "#nitro-internal-pollyfills";
|
||||
import wsAdapter from "crossws/adapters/bun";
|
||||
import { useNitroApp } from "nitropack/runtime";
|
||||
import { startScheduleRunner } from "nitropack/runtime/internal";
|
||||
import { resolveUiTlsPaths } from "./tls-paths.mjs";
|
||||
|
||||
const nitroApp = useNitroApp();
|
||||
const ws = import.meta._websocket
|
||||
@@ -75,8 +78,15 @@ const PEER_IP_HEADER = "x-pf-peer-ip";
|
||||
const LISTENER_HEADER = "x-pf-listener";
|
||||
|
||||
// TLS from the host's identity cert (file PATHS → Bun.file, not PEM-in-env). Absent ⇒ plain HTTP.
|
||||
const certPath = process.env.PUNKTFUNK_UI_TLS_CERT;
|
||||
const keyPath = process.env.PUNKTFUNK_UI_TLS_KEY;
|
||||
//
|
||||
// The launchers all name the LEGACY cert.pem/key.pem pair and cannot express a fallback, so the
|
||||
// choice between the host's two identities is made here — see tls-paths.mjs for why the native
|
||||
// pair is the right one to serve (SANs a browser accepts; the cert the tray and native clients
|
||||
// already pin).
|
||||
const { cert: certPath, key: keyPath } = resolveUiTlsPaths(
|
||||
process.env.PUNKTFUNK_UI_TLS_CERT,
|
||||
process.env.PUNKTFUNK_UI_TLS_KEY,
|
||||
);
|
||||
const tls =
|
||||
certPath && keyPath
|
||||
? { cert: Bun.file(certPath), key: Bun.file(keyPath) }
|
||||
@@ -126,7 +136,8 @@ const listenerOptions = (lane) => ({
|
||||
// is a hooks/library JSON edit, kilobytes. 4 MiB leaves several orders of headroom and still
|
||||
// makes the memory cost of an unauthenticated request negligible.
|
||||
maxRequestBodySize:
|
||||
Number.parseInt(process.env.NITRO_BUN_MAX_BODY_BYTES, 10) || 4 * 1024 * 1024,
|
||||
Number.parseInt(process.env.NITRO_BUN_MAX_BODY_BYTES, 10) ||
|
||||
4 * 1024 * 1024,
|
||||
// `tls: undefined` ⇒ plain HTTP (dev); otherwise HTTPS over HTTP/1.1.
|
||||
tls,
|
||||
websocket: import.meta._websocket ? ws.websocket : undefined,
|
||||
@@ -167,7 +178,9 @@ console.log(`punktfunk web console listening on ${server.url} (tls=${!!tls})`);
|
||||
// this exists to close, and a security boundary that disappears when a port is busy is not one. It
|
||||
// degrades to "plugin UIs unavailable": the console reads the state below and renders an
|
||||
// explanation instead of a frame, and everything else about the console keeps working.
|
||||
const pluginPort = Number(process.env.PUNKTFUNK_UI_PLUGIN_PORT || consolePort + 1);
|
||||
const pluginPort = Number(
|
||||
process.env.PUNKTFUNK_UI_PLUGIN_PORT || consolePort + 1,
|
||||
);
|
||||
let pluginServer;
|
||||
try {
|
||||
pluginServer = Bun.serve({ ...listenerOptions("plugin"), port: pluginPort });
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Which of the host's two identities the console serves — resolved HERE because this entry is the
|
||||
// one place every launcher routes through.
|
||||
//
|
||||
// The host keeps two identities side by side (crate::identity, the "identity split"):
|
||||
//
|
||||
// native-cert.pem / native-key.pem ECDSA P-256, with real SANs (the machine hostname,
|
||||
// localhost, 127.0.0.1, ::1). This is what the native QUIC
|
||||
// plane and the management API present, and what native
|
||||
// clients pin.
|
||||
// cert.pem / key.pem the legacy RSA GameStream identity: CN=punktfunk and NO SAN
|
||||
// at all (gamestream::cert::generate passes rcgen an empty SAN
|
||||
// list), kept byte-stable because Moonlight pins it and the
|
||||
// pairing hashes bind its X.509 signature bytes.
|
||||
//
|
||||
// Every launcher names the LEGACY pair — scripts/punktfunk-web.service, the NixOS module, the
|
||||
// Windows service supervisor, web-run.cmd, the Steam Deck installer — because they were written
|
||||
// before the split, and none of them CAN choose: systemd `Environment=` has no "this file, else
|
||||
// that one". Serving the legacy pair costs twice:
|
||||
//
|
||||
// * a CN-only, SAN-less cert is rejected outright by every current browser
|
||||
// (ERR_CERT_COMMON_NAME_INVALID / SSL_ERROR_BAD_CERT_DOMAIN), so the console the operator was
|
||||
// told to open does not load;
|
||||
// * the tray's loopback liveness probe pins whatever the mgmt API serves — the NATIVE cert — so
|
||||
// the handshake is refused and a perfectly healthy console is labelled "Open web console (not
|
||||
// responding)" while the host beside it reads "idle" (field report 2026-08-24).
|
||||
//
|
||||
// So prefer the native sibling. It is also the smaller secret to hand a bundled bun: on a default
|
||||
// build key.pem is the Moonlight PAIRING SIGNING key, native-key.pem is only a TLS key.
|
||||
//
|
||||
// Swapped as a PAIR or not at all — a native cert with the legacy key is a server that cannot
|
||||
// complete a handshake with anyone, so both halves must be present AND must come from the same
|
||||
// directory. A host that never took the split (upgraded, native clients still pinning the RSA cert,
|
||||
// so `load_or_adopt` keeps serving it) has no native pair on disk and falls through unchanged, as
|
||||
// does a cert an operator supplied under any other name.
|
||||
import { statSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* The directory prefix (separator included) of a path ending in `base`, or null if it does not.
|
||||
*
|
||||
* Deliberately NOT `node:path`: that resolves per-RUNTIME, so a POSIX build reads
|
||||
* `C:\ProgramData\punktfunk\cert.pem` as one long filename — and Windows, where the service
|
||||
* supervisor hands us exactly that (windows/service.rs), is the platform CI can never exercise.
|
||||
* A suffix test gives the same answer everywhere. It also leaves the prefix VERBATIM, where
|
||||
* `join(dirname(p), …)` would normalise `/a/b/../cert.pem` to a different directory than the one
|
||||
* the operator named — which matters the moment `b` is a symlink.
|
||||
*
|
||||
* @param {string} p
|
||||
* @param {string} base
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function dirPrefix(p, base) {
|
||||
if (p === base) return ""; // bare relative name
|
||||
if (!p.endsWith(base)) return null;
|
||||
const sep = p[p.length - base.length - 1];
|
||||
return sep === "/" || sep === "\\" ? p.slice(0, -base.length) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A readable, NON-EMPTY file. Emptiness matters: `pf_paths::write_secret_file` is
|
||||
* create+truncate+write rather than temp+rename, so a console starting mid-write could otherwise
|
||||
* adopt a 0-byte cert and leave `Bun.serve` throwing on every restart — and not every launcher
|
||||
* retries forever (the Steam Deck unit is `Restart=on-failure` under the default rate limit).
|
||||
*
|
||||
* @param {string} p
|
||||
*/
|
||||
function usable(p) {
|
||||
try {
|
||||
return statSync(p).size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | undefined} cert PUNKTFUNK_UI_TLS_CERT, verbatim.
|
||||
* @param {string | undefined} key PUNKTFUNK_UI_TLS_KEY, verbatim.
|
||||
* @param {(p: string) => boolean} [exists] injected by the test; defaults to a real stat.
|
||||
* @returns {{cert: string | undefined, key: string | undefined}}
|
||||
*/
|
||||
export function resolveUiTlsPaths(cert, key, exists = usable) {
|
||||
// Half-configured TLS is the caller's error to report (it refuses to start); don't mask it by
|
||||
// resolving one half of a pair that isn't there.
|
||||
if (!cert || !key) return { cert, key };
|
||||
const dir = dirPrefix(cert, "cert.pem");
|
||||
// Same directory, or we are not looking at a pair — see the PAIR note above.
|
||||
if (dir === null || dir !== dirPrefix(key, "key.pem")) return { cert, key };
|
||||
const nativeCert = `${dir}native-cert.pem`;
|
||||
const nativeKey = `${dir}native-key.pem`;
|
||||
return exists(nativeCert) && exists(nativeKey)
|
||||
? { cert: nativeCert, key: nativeKey }
|
||||
: { cert, key };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// The pair swap is all-or-nothing, and the fallbacks are what keep legacy and custom-cert hosts
|
||||
// serving. A native cert with the legacy key would be a console nobody can handshake with, so the
|
||||
// mixed cases are the ones worth pinning down — including the Windows shape, which the resolver
|
||||
// must get right without a win32 runtime to ask (see dirPrefix in tls-paths.mjs).
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { resolveUiTlsPaths } from "./tls-paths.mjs";
|
||||
|
||||
const DIR = "/home/you/.config/punktfunk";
|
||||
const legacy = [`${DIR}/cert.pem`, `${DIR}/key.pem`] as const;
|
||||
const native = [`${DIR}/native-cert.pem`, `${DIR}/native-key.pem`] as const;
|
||||
/** `exists` over a fixed set of usable files on disk. */
|
||||
const on =
|
||||
(...files: string[]) =>
|
||||
(p: string) =>
|
||||
files.includes(p);
|
||||
|
||||
describe("resolveUiTlsPaths", () => {
|
||||
it("prefers the native pair when both files are there", () => {
|
||||
expect(resolveUiTlsPaths(...legacy, on(...legacy, ...native))).toEqual({
|
||||
cert: native[0],
|
||||
key: native[1],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the legacy pair on a host that never took the identity split", () => {
|
||||
expect(resolveUiTlsPaths(...legacy, on(...legacy))).toEqual({
|
||||
cert: legacy[0],
|
||||
key: legacy[1],
|
||||
});
|
||||
});
|
||||
|
||||
it("never mixes halves when only one native file is usable", () => {
|
||||
for (const half of native) {
|
||||
expect(resolveUiTlsPaths(...legacy, on(...legacy, half))).toEqual({
|
||||
cert: legacy[0],
|
||||
key: legacy[1],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The Windows service supervisor hands us backslash paths (windows/service.rs); node:path on a
|
||||
// POSIX CI runner would read the whole thing as one filename and silently never swap.
|
||||
it("resolves Windows paths without a win32 runtime", () => {
|
||||
const win = ["C:\\ProgramData\\punktfunk", "D:\\pf"] as const;
|
||||
for (const d of win) {
|
||||
expect(
|
||||
resolveUiTlsPaths(`${d}\\cert.pem`, `${d}\\key.pem`, () => true),
|
||||
).toEqual({
|
||||
cert: `${d}\\native-cert.pem`,
|
||||
key: `${d}\\native-key.pem`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to pair halves from two different directories", () => {
|
||||
expect(resolveUiTlsPaths("/a/cert.pem", "/b/key.pem", () => true)).toEqual({
|
||||
cert: "/a/cert.pem",
|
||||
key: "/b/key.pem",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the prefix verbatim rather than normalising it away", () => {
|
||||
// `join(dirname(p), …)` would collapse this to /a/native-cert.pem — a different directory
|
||||
// the moment `b` is a symlink.
|
||||
expect(
|
||||
resolveUiTlsPaths("/a/b/../cert.pem", "/a/b/../key.pem", () => true),
|
||||
).toEqual({
|
||||
cert: "/a/b/../native-cert.pem",
|
||||
key: "/a/b/../native-key.pem",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an operator's own cert alone, native pair present or not", () => {
|
||||
// Also covers the endsWith trap: "mycert.pem" ends with "cert.pem" but is not one.
|
||||
for (const own of [
|
||||
[`${DIR}/lan-ca.pem`, `${DIR}/lan-ca.key`],
|
||||
[`${DIR}/mycert.pem`, `${DIR}/mykey.pem`],
|
||||
] as const) {
|
||||
expect(resolveUiTlsPaths(...own, on(...own, ...native))).toEqual({
|
||||
cert: own[0],
|
||||
key: own[1],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("does not re-swap a pair that already names the native files", () => {
|
||||
expect(resolveUiTlsPaths(...native, () => true)).toEqual({
|
||||
cert: native[0],
|
||||
key: native[1],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes a half-configured pair through for the entry to refuse", () => {
|
||||
expect(resolveUiTlsPaths(legacy[0], undefined, on(...native))).toEqual({
|
||||
cert: legacy[0],
|
||||
key: undefined,
|
||||
});
|
||||
expect(resolveUiTlsPaths(undefined, undefined, on(...native))).toEqual({
|
||||
cert: undefined,
|
||||
key: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
"start": "bun run .output/server/index.mjs",
|
||||
"api:gen": "orval --config orval.config.ts",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "bun test server/",
|
||||
"test": "bun test server/ nitro-entry/",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"screenshots": "node tools/screenshots.mjs",
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ export default defineConfig({
|
||||
// stock self-listening entry for ours (`nitro-entry/bun-https.mjs`), which calls
|
||||
// `Bun.serve({ tls })` so the console is served over HTTPS (HTTP/1.1 over TLS) with the
|
||||
// host's own identity cert. (No HTTP/2 — Bun.serve has no h2 server — and no HTTP/3, which a
|
||||
// browser won't speak against this self-signed, no-SAN host cert.) Bun is the runtime
|
||||
// browser won't speak against a self-signed host cert.) Bun is the runtime
|
||||
// everywhere now — the Windows installer already bundles it, and the punktfunk-web .deb
|
||||
// vendors it (it can't be `node`: `Bun.serve` is a bun API). (dev `vite dev` is unaffected.)
|
||||
preset: "bun",
|
||||
|
||||
@@ -51,6 +51,9 @@ if exist "%ENDPOINTFILE%" for /f "usebackq tokens=1* delims==" %%A in ("%ENDPOIN
|
||||
rem No NODE_TLS_REJECT_UNAUTHORIZED: the host's self-signed cert is accepted only for the loopback
|
||||
rem proxy hop, scoped inside the proxy code (Bun per-request TLS), not process-wide.
|
||||
rem Serve HTTPS (HTTP/1.1 over TLS) with the host's identity cert; mark the session cookie Secure.
|
||||
rem These name the LEGACY pair; the server prefers native-cert.pem/native-key.pem beside them when
|
||||
rem both exist (the identity split - web\nitro-entry\tls-paths.mjs). Don't "fix" them to the native
|
||||
rem names: a host that never took the split has no native pair, and the fallback lives in there.
|
||||
set "PUNKTFUNK_UI_TLS_CERT=%CERTFILE%"
|
||||
set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%"
|
||||
set "PUNKTFUNK_UI_SECURE=1"
|
||||
|
||||
+3
-1
@@ -18,7 +18,9 @@ HOST=0.0.0.0
|
||||
|
||||
# Serve the console over HTTPS (HTTP/1.1 over TLS) with the host's own identity cert. BOTH paths
|
||||
# set ⇒ HTTPS. (No HTTP/2 or HTTP/3: Bun.serve has no HTTP/2 server, and a browser won't speak
|
||||
# HTTP/3/QUIC against this self-signed, no-SAN host cert — so HTTP/1.1 over TLS is what's offered.)
|
||||
# HTTP/3/QUIC against a self-signed host cert — so HTTP/1.1 over TLS is what's offered.)
|
||||
# Name the LEGACY pair below: the server prefers native-cert.pem/native-key.pem beside it when both
|
||||
# exist (nitro-entry/tls-paths.mjs), and falls back to these on a host that never took the split.
|
||||
PUNKTFUNK_UI_TLS_CERT=%h/.config/punktfunk/cert.pem
|
||||
PUNKTFUNK_UI_TLS_KEY=%h/.config/punktfunk/key.pem
|
||||
# Mark the session cookie Secure (required once served over TLS):
|
||||
|
||||
Reference in New Issue
Block a user