Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4fe4d0792 | ||
|
|
087e7d0405 | ||
|
|
64511af4d3 | ||
|
|
86d5d66660 | ||
|
|
a3e21d9227 | ||
|
|
e35dad529b | ||
|
|
9b241d9d7b |
Generated
+29
-27
@@ -2194,7 +2194,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2299,7 +2299,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libvpl-sys"
|
name = "libvpl-sys"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cmake",
|
"cmake",
|
||||||
@@ -2334,7 +2334,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2823,7 +2823,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-capture"
|
name = "pf-capture"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2844,7 +2844,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-client-core"
|
name = "pf-client-core"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2868,7 +2868,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-clipboard"
|
name = "pf-clipboard"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2886,7 +2886,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-console-ui"
|
name = "pf-console-ui"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2907,7 +2907,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-encode"
|
name = "pf-encode"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2931,7 +2931,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-ffvk"
|
name = "pf-ffvk"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -2940,7 +2940,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-frame"
|
name = "pf-frame"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2952,7 +2952,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-gpu"
|
name = "pf-gpu"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-host-config",
|
"pf-host-config",
|
||||||
@@ -2966,11 +2966,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-host-config"
|
name = "pf-host-config"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-inject"
|
name = "pf-inject"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2981,6 +2981,7 @@ dependencies = [
|
|||||||
"pf-driver-proto",
|
"pf-driver-proto",
|
||||||
"pf-host-config",
|
"pf-host-config",
|
||||||
"pf-paths",
|
"pf-paths",
|
||||||
|
"pf-win-display",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
"reis",
|
"reis",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -2998,14 +2999,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-paths"
|
name = "pf-paths"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-presenter"
|
name = "pf-presenter"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3020,10 +3021,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-vdisplay"
|
name = "pf-vdisplay"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
|
"bitflags",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
@@ -3050,7 +3052,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-win-display"
|
name = "pf-win-display"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-paths",
|
"pf-paths",
|
||||||
@@ -3062,7 +3064,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-zerocopy"
|
name = "pf-zerocopy"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3269,7 +3271,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -3285,7 +3287,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3301,7 +3303,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-session"
|
name = "punktfunk-client-session"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
@@ -3316,7 +3318,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
@@ -3335,7 +3337,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3367,7 +3369,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3451,7 +3453,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3465,7 +3467,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
@@ -3488,7 +3490,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyrowave-sys"
|
name = "pyrowave-sys"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cmake",
|
"cmake",
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ exclude = [
|
|||||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ xkbcommon = "0.8"
|
|||||||
usbip-sim = { path = "../punktfunk-host/vendor/usbip-sim" }
|
usbip-sim = { path = "../punktfunk-host/vendor/usbip-sim" }
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
# The streamed-output desktop rect (CCD source rect) absolute input maps into — the same
|
||||||
|
# resolver the cursor-readback poller uses, so inject and readback always agree.
|
||||||
|
pf-win-display = { path = "../pf-win-display" }
|
||||||
windows = { version = "0.62", features = [
|
windows = { version = "0.62", features = [
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
"Win32_Security",
|
"Win32_Security",
|
||||||
|
|||||||
@@ -41,9 +41,8 @@ use windows::Win32::UI::Input::Pointer::{
|
|||||||
POINTER_FLAG_UPDATE, POINTER_INFO, POINTER_PEN_INFO, POINTER_TOUCH_INFO,
|
POINTER_FLAG_UPDATE, POINTER_INFO, POINTER_PEN_INFO, POINTER_TOUCH_INFO,
|
||||||
};
|
};
|
||||||
use windows::Win32::UI::WindowsAndMessaging::{
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
GetSystemMetrics, PEN_FLAG_BARREL, PEN_FLAG_ERASER, PEN_FLAG_INVERTED, PEN_FLAG_NONE,
|
PEN_FLAG_BARREL, PEN_FLAG_ERASER, PEN_FLAG_INVERTED, PEN_FLAG_NONE, PEN_MASK_PRESSURE,
|
||||||
PEN_MASK_PRESSURE, PEN_MASK_ROTATION, PEN_MASK_TILT_X, PEN_MASK_TILT_Y, PT_PEN, PT_TOUCH,
|
PEN_MASK_ROTATION, PEN_MASK_TILT_X, PEN_MASK_TILT_Y, PT_PEN, PT_TOUCH, TOUCH_FLAG_NONE,
|
||||||
SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, TOUCH_FLAG_NONE,
|
|
||||||
TOUCH_MASK_NONE,
|
TOUCH_MASK_NONE,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,22 +148,14 @@ unsafe fn inject_following_desktop(
|
|||||||
/// Windows pen pressure is 0..1024 (vs the wire's full-scale u16).
|
/// Windows pen pressure is 0..1024 (vs the wire's full-scale u16).
|
||||||
const WIN_PEN_PRESSURE_MAX: u32 = 1024;
|
const WIN_PEN_PRESSURE_MAX: u32 = 1024;
|
||||||
|
|
||||||
/// Map a normalized [0,1] coordinate pair onto virtual-desktop pixels — the same surface the
|
/// Map a normalized [0,1] coordinate pair onto desktop pixels over the STREAMED output's rect
|
||||||
/// SendInput absolute mouse targets, so pen, touch, and pointer all land identically.
|
/// ([`crate::stream_target`]) — the surface the SendInput absolute mouse targets too, so pen,
|
||||||
|
/// touch, and pointer all land identically. The wire normalizes over the streamed display's
|
||||||
|
/// frame, not the desktop: mapping over the whole virtual desktop is only right when they
|
||||||
|
/// coincide (Exclusive topology — the fallback when no stream target is live).
|
||||||
fn to_screen(x: f32, y: f32) -> POINT {
|
fn to_screen(x: f32, y: f32) -> POINT {
|
||||||
// SAFETY: `GetSystemMetrics` takes a constant index and reads global metrics; no memory in.
|
let (px, py) = crate::stream_target::map_normalized(x as f64, y as f64);
|
||||||
let (vx, vy, vw, vh) = unsafe {
|
POINT { x: px, y: py }
|
||||||
(
|
|
||||||
GetSystemMetrics(SM_XVIRTUALSCREEN),
|
|
||||||
GetSystemMetrics(SM_YVIRTUALSCREEN),
|
|
||||||
GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1),
|
|
||||||
GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
POINT {
|
|
||||||
x: vx + (x.clamp(0.0, 1.0) * (vw - 1) as f32) as i32,
|
|
||||||
y: vy + (y.clamp(0.0, 1.0) * (vh - 1) as f32) as i32,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An owned `HSYNTHETICPOINTERDEVICE` (destroyed exactly once on drop).
|
/// An owned `HSYNTHETICPOINTERDEVICE` (destroyed exactly once on drop).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Windows input injection via `SendInput` (Win32 KeyboardAndMouse) — the Windows analogue of
|
//! Windows input injection via `SendInput` (Win32 KeyboardAndMouse) — the Windows analogue of
|
||||||
//! [`super::wlr`]: absolute mouse normalized to the virtual desktop, relative mouse for games,
|
//! [`super::wlr`]: absolute mouse mapped over the streamed output's rect
|
||||||
//! scancode keyboard, scroll, buttons. Survives UAC/lock desktop switches with Sunshine's
|
//! ([`crate::stream_target`]), relative mouse for games, scancode keyboard, scroll, buttons. Survives UAC/lock desktop switches with Sunshine's
|
||||||
//! retry-on-failure model: the thread stays bound to its desktop and only reattaches
|
//! retry-on-failure model: the thread stays bound to its desktop and only reattaches
|
||||||
//! (`OpenInputDesktop`/`SetThreadDesktop`) when `SendInput` reports a short write (the input
|
//! (`OpenInputDesktop`/`SetThreadDesktop`) when `SendInput` reports a short write (the input
|
||||||
//! desktop switched) — no per-event reattach overhead.
|
//! desktop switched) — no per-event reattach overhead.
|
||||||
@@ -32,14 +32,10 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{
|
|||||||
MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK,
|
MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK,
|
||||||
MOUSEEVENTF_WHEEL, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, VIRTUAL_KEY,
|
MOUSEEVENTF_WHEEL, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, VIRTUAL_KEY,
|
||||||
};
|
};
|
||||||
use windows::Win32::UI::WindowsAndMessaging::{
|
use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId};
|
||||||
GetForegroundWindow, GetSystemMetrics, GetWindowThreadProcessId, SM_CXVIRTUALSCREEN,
|
|
||||||
SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::InputInjector;
|
use super::InputInjector;
|
||||||
|
|
||||||
const ABS_MAX: f64 = 65535.0; // SendInput absolute coords are 0..65535 over the chosen surface.
|
|
||||||
const GENERIC_ALL: u32 = 0x1000_0000;
|
const GENERIC_ALL: u32 = 0x1000_0000;
|
||||||
const XBUTTON1: u32 = 0x0001;
|
const XBUTTON1: u32 = 0x0001;
|
||||||
const XBUTTON2: u32 = 0x0002;
|
const XBUTTON2: u32 = 0x0002;
|
||||||
@@ -165,13 +161,16 @@ impl InputInjector for SendInputInjector {
|
|||||||
if w == 0 || h == 0 {
|
if w == 0 || h == 0 {
|
||||||
return Ok(()); // contract: drop zero extent
|
return Ok(()); // contract: drop zero extent
|
||||||
}
|
}
|
||||||
let (_vx, _vy, vw, vh) = virtual_desktop_rect();
|
// Client (0..w,0..h) → the STREAMED output's desktop rect
|
||||||
// One virtual output spanning the virtual desktop: map client (0..w,0..h) -> 0..65535.
|
// ([`crate::stream_target`]; the whole virtual desktop only as fallback) →
|
||||||
|
// 0..65535 over the virtual desktop for MOUSEEVENTF_VIRTUALDESK. Mapping over
|
||||||
|
// the desktop alone is the Extend-topology offset bug the pen plane exposed
|
||||||
|
// (design/pen-tablet-input.md): correct only when the streamed display IS the
|
||||||
|
// whole desktop.
|
||||||
let cx = (event.x.clamp(0, w as i32)) as f64 / w as f64;
|
let cx = (event.x.clamp(0, w as i32)) as f64 / w as f64;
|
||||||
let cy = (event.y.clamp(0, h as i32)) as f64 / h as f64;
|
let cy = (event.y.clamp(0, h as i32)) as f64 / h as f64;
|
||||||
let ax = (cx * ABS_MAX).round() as i32;
|
let px = crate::stream_target::map_normalized(cx, cy);
|
||||||
let ay = (cy * ABS_MAX).round() as i32;
|
let (ax, ay) = crate::stream_target::desktop_px_to_virtualdesk(px);
|
||||||
let _ = (vw, vh); // virtual-desktop rect reserved for multi-output mapping
|
|
||||||
let mi = MOUSEINPUT {
|
let mi = MOUSEINPUT {
|
||||||
dx: ax,
|
dx: ax,
|
||||||
dy: ay,
|
dy: ay,
|
||||||
@@ -378,19 +377,6 @@ fn key(ki: KEYBDINPUT) -> INPUT {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn virtual_desktop_rect() -> (i32, i32, i32, i32) {
|
|
||||||
// SAFETY: each `GetSystemMetrics` takes a single by-value `SYSTEM_METRICS_INDEX` constant and
|
|
||||||
// returns an `i32`; it dereferences no pointer and has no side effects — FFI-`unsafe` only.
|
|
||||||
unsafe {
|
|
||||||
(
|
|
||||||
GetSystemMetrics(SM_XVIRTUALSCREEN),
|
|
||||||
GetSystemMetrics(SM_YVIRTUALSCREEN),
|
|
||||||
GetSystemMetrics(SM_CXVIRTUALSCREEN),
|
|
||||||
GetSystemMetrics(SM_CYVIRTUALSCREEN),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// VKs Windows wants flagged extended even when the scancode high bits aren't set: the editing
|
// VKs Windows wants flagged extended even when the scancode high bits aren't set: the editing
|
||||||
// cluster (Ins/Del/Home/End/PgUp/PgDn = 0x21..0x28, 0x2D, 0x2E), the Win keys (0x5B/0x5C/0x5D),
|
// cluster (Ins/Del/Home/End/PgUp/PgDn = 0x21..0x28, 0x2D, 0x2E), the Win keys (0x5B/0x5C/0x5D),
|
||||||
// RCtrl (0xA3), RAlt (0xA5), Pause (0x90). MAPVK_VK_TO_VSC_EX already encodes E0 for most; this is a
|
// RCtrl (0xA3), RAlt (0xA5), Pause (0x90). MAPVK_VK_TO_VSC_EX already encodes E0 for most; this is a
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
//! The streamed display every absolute coordinate maps into (design/pen-tablet-input.md field
|
||||||
|
//! fix). Pen, touch, and absolute-mouse positions arrive normalized to the STREAMED output's
|
||||||
|
//! frame, but the injectors historically mapped them over the whole virtual desktop — correct
|
||||||
|
//! only when the virtual display is the sole active display (Exclusive topology, normalized to
|
||||||
|
//! origin). In Extend — a physical monitor kept on beside the virtual output, or an Exclusive
|
||||||
|
//! isolate degraded to the keep-physicals fallback — the streamed output sits at a non-zero
|
||||||
|
//! origin, so every sample landed shifted and mis-scaled (the pen exposed it first: a stylus is
|
||||||
|
//! strictly absolute, with no closed-loop correction onto the target like a cursor).
|
||||||
|
//!
|
||||||
|
//! The host publishes the streamed output's CCD target id at capture bring-up
|
||||||
|
//! ([`set_stream_target`]); the mapping sites resolve its CURRENT desktop rect through
|
||||||
|
//! [`pf_win_display::win_display::source_desktop_rect`] — the same resolver the cursor-readback
|
||||||
|
//! poller maps frames with, so the two directions always agree — TTL-cached because a
|
||||||
|
//! group-layout re-arrange moves a live output's origin mid-session. With no target set, or none
|
||||||
|
//! resolved yet, mapping falls back to the whole virtual desktop: the historical behavior, still
|
||||||
|
//! correct for Exclusive topology and the client-less devtest paths.
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
|
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `(x, y, w, h)` in desktop coordinates, physical pixels (`source_desktop_rect` order).
|
||||||
|
type Rect = (i32, i32, i32, i32);
|
||||||
|
|
||||||
|
/// How long a resolved rect stays fresh: long enough that the CCD query cost vanishes at input
|
||||||
|
/// rates (pen samples + the 40 ms refresh threads), short enough that a mid-session layout move
|
||||||
|
/// (a parallel session joining the auto-row) is picked up within a blink.
|
||||||
|
const RECT_TTL: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
|
struct State {
|
||||||
|
target_id: Option<u32>,
|
||||||
|
rect: Option<Rect>,
|
||||||
|
queried: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static STATE: Mutex<State> = Mutex::new(State {
|
||||||
|
target_id: None,
|
||||||
|
rect: None,
|
||||||
|
queried: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Publish the streamed output (its CCD target id) that absolute input maps into. The host calls
|
||||||
|
/// this at capture bring-up; it is never cleared at teardown — a deactivated target simply stops
|
||||||
|
/// resolving (the last-known rect is kept, and nothing injects between sessions), and the next
|
||||||
|
/// session's bring-up re-targets. One slot per process: with parallel sessions the LAST bring-up
|
||||||
|
/// wins for every session's absolute input — per-session routing needs source-tagged input
|
||||||
|
/// events (parallel-displays plan) and the single slot is never worse than the historical
|
||||||
|
/// whole-desktop mapping.
|
||||||
|
pub fn set_stream_target(target_id: Option<u32>) {
|
||||||
|
let mut st = STATE.lock().unwrap();
|
||||||
|
if st.target_id != target_id {
|
||||||
|
tracing::info!(?target_id, "absolute-input stream target set");
|
||||||
|
st.target_id = target_id;
|
||||||
|
st.rect = None;
|
||||||
|
st.queried = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The streamed output's current desktop rect, TTL-cached. `None` = no target set / never
|
||||||
|
/// resolved (callers fall back to the whole virtual desktop).
|
||||||
|
fn stream_rect() -> Option<Rect> {
|
||||||
|
let mut st = STATE.lock().unwrap();
|
||||||
|
let target_id = st.target_id?;
|
||||||
|
let fresh = st.queried.is_some_and(|at| at.elapsed() < RECT_TTL);
|
||||||
|
if !fresh {
|
||||||
|
st.queried = Some(Instant::now());
|
||||||
|
// SAFETY: read-only QueryDisplayConfig over owned locals (`source_desktop_rect`'s
|
||||||
|
// contract — the same call the cursor-readback poller makes at spawn).
|
||||||
|
match unsafe { pf_win_display::win_display::source_desktop_rect(target_id) } {
|
||||||
|
Some(r) => {
|
||||||
|
if st.rect != Some(r) {
|
||||||
|
tracing::info!(target_id, rect = ?r, "stream-target desktop rect resolved");
|
||||||
|
}
|
||||||
|
st.rect = Some(r);
|
||||||
|
}
|
||||||
|
// Not an active path right now (teardown, or a topology commit in flight): keep the
|
||||||
|
// last-known rect — snapping mid-stroke to the whole-desktop mapping would visibly
|
||||||
|
// jump, and after teardown nothing injects until the next session re-targets.
|
||||||
|
None => {
|
||||||
|
if st.rect.is_some() {
|
||||||
|
tracing::debug!(
|
||||||
|
target_id,
|
||||||
|
"stream target not an active path — keeping last rect"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.rect
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Desktop-space pixel for a normalized `[0,1]²` coordinate over the streamed output's rect,
|
||||||
|
/// falling back to the whole virtual desktop when no stream target is live.
|
||||||
|
pub(crate) fn map_normalized(nx: f64, ny: f64) -> (i32, i32) {
|
||||||
|
map_into(stream_rect().unwrap_or_else(virtual_desktop_rect), nx, ny)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure mapping: `[0,1]²` over `(x, y, w, h)`, inclusive edges (1.0 lands on the last pixel).
|
||||||
|
fn map_into((x, y, w, h): Rect, nx: f64, ny: f64) -> (i32, i32) {
|
||||||
|
(
|
||||||
|
x + (nx.clamp(0.0, 1.0) * (w - 1).max(0) as f64).round() as i32,
|
||||||
|
y + (ny.clamp(0.0, 1.0) * (h - 1).max(0) as f64).round() as i32,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The virtual-desktop bounds `(x, y, w, h)` — the mapping fallback, and the surface
|
||||||
|
/// `MOUSEEVENTF_VIRTUALDESK` absolute coordinates normalize over.
|
||||||
|
pub(crate) fn virtual_desktop_rect() -> Rect {
|
||||||
|
// SAFETY: each `GetSystemMetrics` takes a single by-value `SYSTEM_METRICS_INDEX` constant and
|
||||||
|
// returns an `i32`; it dereferences no pointer and has no side effects — FFI-`unsafe` only.
|
||||||
|
unsafe {
|
||||||
|
(
|
||||||
|
GetSystemMetrics(SM_XVIRTUALSCREEN),
|
||||||
|
GetSystemMetrics(SM_YVIRTUALSCREEN),
|
||||||
|
GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1),
|
||||||
|
GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A desktop-space pixel as the `MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK` 0..65535
|
||||||
|
/// coordinate pair `SendInput` wants.
|
||||||
|
pub(crate) fn desktop_px_to_virtualdesk(px: (i32, i32)) -> (i32, i32) {
|
||||||
|
px_to_abs(virtual_desktop_rect(), px)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SendInput absolute coordinates span 0..65535 over the chosen surface.
|
||||||
|
const ABS_MAX: f64 = 65535.0;
|
||||||
|
|
||||||
|
/// Pure normalization: a desktop pixel inside `(x, y, w, h)` → 0..65535 over that surface.
|
||||||
|
fn px_to_abs((vx, vy, vw, vh): Rect, (px, py): (i32, i32)) -> (i32, i32) {
|
||||||
|
(
|
||||||
|
((px - vx) as f64 * ABS_MAX / (vw - 1).max(1) as f64).round() as i32,
|
||||||
|
((py - vy) as f64 * ABS_MAX / (vh - 1).max(1) as f64).round() as i32,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The Extend-topology field bug: physical 1920x1080 at (0,0), streamed virtual 2560x1440
|
||||||
|
/// beside it at (1920,0) — samples must land inside the VIRTUAL output, not at the desktop
|
||||||
|
/// origin.
|
||||||
|
#[test]
|
||||||
|
fn maps_over_the_streamed_rect_not_the_desktop() {
|
||||||
|
let r = (1920, 0, 2560, 1440);
|
||||||
|
assert_eq!(map_into(r, 0.0, 0.0), (1920, 0));
|
||||||
|
assert_eq!(map_into(r, 1.0, 1.0), (1920 + 2559, 1439));
|
||||||
|
assert_eq!(map_into(r, 0.5, 0.5), (1920 + 1280, 720));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clamps_out_of_range_and_handles_negative_origins() {
|
||||||
|
// An output placed LEFT of / ABOVE the primary has a negative desktop origin.
|
||||||
|
let r = (-2560, -100, 2560, 1440);
|
||||||
|
assert_eq!(map_into(r, 0.0, 0.0), (-2560, -100));
|
||||||
|
assert_eq!(map_into(r, 2.0, -1.0), (-2560 + 2559, -100));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn degenerate_rect_pins_to_its_origin() {
|
||||||
|
assert_eq!(map_into((10, 20, 0, 0), 0.7, 0.7), (10, 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The VIRTUALDESK round trip: win32k maps an absolute coordinate back to a pixel roughly as
|
||||||
|
/// `px = ax * vw / 65536` (floor) — edge pixels and the streamed output's origin must survive.
|
||||||
|
#[test]
|
||||||
|
fn virtualdesk_normalization_round_trips() {
|
||||||
|
let v = (0, 0, 4480, 1080);
|
||||||
|
assert_eq!(px_to_abs(v, (0, 0)), (0, 0));
|
||||||
|
assert_eq!(px_to_abs(v, (4479, 1079)), (65535, 65535));
|
||||||
|
let (ax, _) = px_to_abs(v, (1920, 0));
|
||||||
|
assert_eq!((ax as i64 * 4480 / 65536) as i32, 1920);
|
||||||
|
// Negative-origin desktops (a monitor left of the primary) still normalize from 0.
|
||||||
|
let v = (-2560, 0, 4480, 1440);
|
||||||
|
assert_eq!(px_to_abs(v, (-2560, 0)), (0, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -417,6 +417,16 @@ pub mod pen;
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "inject/windows/pointer_windows.rs"]
|
#[path = "inject/windows/pointer_windows.rs"]
|
||||||
pub mod pen;
|
pub mod pen;
|
||||||
|
/// Windows: the streamed output's desktop rect that every absolute coordinate (pen, touch,
|
||||||
|
/// absolute mouse) maps into — published by the host at capture bring-up, resolved through the
|
||||||
|
/// CCD source rect (the cursor-readback poller's resolver, so both directions agree). Mapping
|
||||||
|
/// over the whole virtual desktop instead is the Extend-topology offset bug the pen exposed
|
||||||
|
/// (design/pen-tablet-input.md).
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "inject/windows/stream_target.rs"]
|
||||||
|
pub mod stream_target;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub use stream_target::set_stream_target;
|
||||||
/// Stub — pen injection needs the Linux uinput tablet or Windows synthetic pointers;
|
/// Stub — pen injection needs the Linux uinput tablet or Windows synthetic pointers;
|
||||||
/// `pen_supported()` is false here, so no host advertises the cap and no batches arrive.
|
/// `pen_supported()` is false here, so no host advertises the cap and no batches arrive.
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ futures-util = "0.3"
|
|||||||
wayland-client = "0.31"
|
wayland-client = "0.31"
|
||||||
wayland-scanner = "0.31"
|
wayland-scanner = "0.31"
|
||||||
wayland-backend = "0.3"
|
wayland-backend = "0.3"
|
||||||
|
# wayland-scanner emits `bitflags::bitflags!` for the KDE output-device protocol's bitfield enums
|
||||||
|
# (kde-output-device-v2 `capability`/`flags`); needs the crate in scope (kwin_output_mgmt.rs).
|
||||||
|
bitflags = "2"
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
# The host<->driver wire contract for the pf-vdisplay IddCx backend (control IOCTLs + Pod structs).
|
# The host<->driver wire contract for the pf-vdisplay IddCx backend (control IOCTLs + Pod structs).
|
||||||
|
|||||||
@@ -0,0 +1,653 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<protocol name="kde_output_device_v2">
|
||||||
|
<copyright><![CDATA[
|
||||||
|
SPDX-FileCopyrightText: 2008-2011 Kristian Høgsberg
|
||||||
|
SPDX-FileCopyrightText: 2010-2011 Intel Corporation
|
||||||
|
SPDX-FileCopyrightText: 2012-2013 Collabora, Ltd.
|
||||||
|
SPDX-FileCopyrightText: 2015 Sebastian Kügler <sebas@kde.org>
|
||||||
|
SPDX-FileCopyrightText: 2021 Méven Car <meven.car@enioka.com>
|
||||||
|
|
||||||
|
SPDX-License-Identifier: MIT-CMU
|
||||||
|
]]></copyright>
|
||||||
|
|
||||||
|
<interface name="kde_output_device_registry_v2" version="24">
|
||||||
|
<description summary="output devices">
|
||||||
|
This interface can be used to list output devices.
|
||||||
|
|
||||||
|
If this global is bound with a version less than 21, the unsupported_version
|
||||||
|
protocol error will be posted.
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<enum name="error">
|
||||||
|
<description summary="kde_output_device_registry_v2 error values">
|
||||||
|
These errors can be emitted in response to some requests.
|
||||||
|
</description>
|
||||||
|
<entry name="unsupported_version" value="0"
|
||||||
|
summary="the registry was bound with an unsupported version"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<event name="finished" type="destructor" since="21">
|
||||||
|
<description summary="no new output announcements">
|
||||||
|
This event is sent in response to the stop request. The compositor will
|
||||||
|
immediately destroy the object after sending this event.
|
||||||
|
</description>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<request name="stop" since="21">
|
||||||
|
<description summary="stop receiving updates">
|
||||||
|
This request indicates that the client no longer wants to receive new
|
||||||
|
output announcements. The compositor will send the
|
||||||
|
kde_output_device_registry_v2.finished event in response to this request.
|
||||||
|
The compositor may still send new output announcements after calling this
|
||||||
|
request until the kde_output_device_registry_v2.finished event is sent.
|
||||||
|
</description>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<event name="output" since="21">
|
||||||
|
<description summary="new available output">
|
||||||
|
This event is sent when a new output is connected or after binding this
|
||||||
|
global to list all available outputs.
|
||||||
|
</description>
|
||||||
|
<arg name="output" type="new_id" interface="kde_output_device_v2"/>
|
||||||
|
</event>
|
||||||
|
</interface>
|
||||||
|
|
||||||
|
<interface name="kde_output_device_v2" version="24">
|
||||||
|
<description summary="output configuration representation">
|
||||||
|
An output device describes a display device available to the compositor.
|
||||||
|
output_device is similar to wl_output, but focuses on output
|
||||||
|
configuration management.
|
||||||
|
|
||||||
|
A client can query all global output_device objects to enlist all
|
||||||
|
available display devices, even those that may currently not be
|
||||||
|
represented by the compositor as a wl_output.
|
||||||
|
|
||||||
|
The client sends configuration changes to the server through the
|
||||||
|
outputconfiguration interface, and the server applies the configuration
|
||||||
|
changes to the hardware and signals changes to the output devices
|
||||||
|
accordingly.
|
||||||
|
|
||||||
|
This object is published as global during start up for every available
|
||||||
|
display devices, or when one later becomes available, for example by
|
||||||
|
being hotplugged via a physical connector.
|
||||||
|
|
||||||
|
Warning! The protocol described in this file is a desktop environment
|
||||||
|
implementation detail. Regular clients must not use this protocol.
|
||||||
|
Backward incompatible changes may be added without bumping the major
|
||||||
|
version of the extension.
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<enum name="subpixel">
|
||||||
|
<description summary="subpixel geometry information">
|
||||||
|
This enumeration describes how the physical pixels on an output are
|
||||||
|
laid out.
|
||||||
|
</description>
|
||||||
|
<entry name="unknown" value="0"/>
|
||||||
|
<entry name="none" value="1"/>
|
||||||
|
<entry name="horizontal_rgb" value="2"/>
|
||||||
|
<entry name="horizontal_bgr" value="3"/>
|
||||||
|
<entry name="vertical_rgb" value="4"/>
|
||||||
|
<entry name="vertical_bgr" value="5"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<enum name="transform">
|
||||||
|
<description summary="transform from framebuffer to output">
|
||||||
|
This describes the transform, that a compositor will apply to a
|
||||||
|
surface to compensate for the rotation or mirroring of an
|
||||||
|
output device.
|
||||||
|
|
||||||
|
The flipped values correspond to an initial flip around a
|
||||||
|
vertical axis followed by rotation.
|
||||||
|
|
||||||
|
The purpose is mainly to allow clients to render accordingly and
|
||||||
|
tell the compositor, so that for fullscreen surfaces, the
|
||||||
|
compositor is still able to scan out directly client surfaces.
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<entry name="normal" value="0"/>
|
||||||
|
<entry name="90" value="1"/>
|
||||||
|
<entry name="180" value="2"/>
|
||||||
|
<entry name="270" value="3"/>
|
||||||
|
<entry name="flipped" value="4"/>
|
||||||
|
<entry name="flipped_90" value="5"/>
|
||||||
|
<entry name="flipped_180" value="6"/>
|
||||||
|
<entry name="flipped_270" value="7"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<event name="geometry">
|
||||||
|
<description summary="geometric properties of the output">
|
||||||
|
The geometry event describes geometric properties of the output.
|
||||||
|
The event is sent when binding to the output object and whenever
|
||||||
|
any of the properties change.
|
||||||
|
</description>
|
||||||
|
<arg name="x" type="int"
|
||||||
|
summary="x position within the global compositor space"/>
|
||||||
|
<arg name="y" type="int"
|
||||||
|
summary="y position within the global compositor space"/>
|
||||||
|
<arg name="physical_width" type="int"
|
||||||
|
summary="width in millimeters of the output"/>
|
||||||
|
<arg name="physical_height" type="int"
|
||||||
|
summary="height in millimeters of the output"/>
|
||||||
|
<arg name="subpixel" type="int"
|
||||||
|
summary="subpixel orientation of the output"/>
|
||||||
|
<arg name="make" type="string"
|
||||||
|
summary="textual description of the manufacturer"/>
|
||||||
|
<arg name="model" type="string"
|
||||||
|
summary="textual description of the model"/>
|
||||||
|
<arg name="transform" type="int"
|
||||||
|
summary="transform that maps framebuffer to output"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="current_mode">
|
||||||
|
<description summary="current mode">
|
||||||
|
This event describes the mode currently in use for this head. It is only
|
||||||
|
sent if the output is enabled.
|
||||||
|
</description>
|
||||||
|
<arg name="mode" type="object" interface="kde_output_device_mode_v2"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="mode">
|
||||||
|
<description summary="advertise available output modes and current one">
|
||||||
|
The mode event describes an available mode for the output.
|
||||||
|
|
||||||
|
When the client binds to the output_device object, the server sends this
|
||||||
|
event once for every available mode the output_device can be operated by.
|
||||||
|
|
||||||
|
There will always be at least one event sent out on initial binding,
|
||||||
|
which represents the current mode.
|
||||||
|
|
||||||
|
Later if an output changes, its mode event is sent again for the
|
||||||
|
eventual added modes and lastly the current mode. In other words, the
|
||||||
|
current mode is always represented by the latest event sent with the current
|
||||||
|
flag set.
|
||||||
|
|
||||||
|
The size of a mode is given in physical hardware units of the output device.
|
||||||
|
This is not necessarily the same as the output size in the global compositor
|
||||||
|
space. For instance, the output may be scaled, as described in
|
||||||
|
kde_output_device_v2.scale, or transformed, as described in
|
||||||
|
kde_output_device_v2.transform.
|
||||||
|
</description>
|
||||||
|
<arg name="mode" type="new_id" interface="kde_output_device_mode_v2"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="done">
|
||||||
|
<description summary="sent all information about output">
|
||||||
|
This event is sent after all other properties have been
|
||||||
|
sent on binding to the output object as well as after any
|
||||||
|
other output property change have been applied later on.
|
||||||
|
This allows to see changes to the output properties as atomic,
|
||||||
|
even if multiple events successively announce them.
|
||||||
|
</description>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="scale">
|
||||||
|
<description summary="output scaling properties">
|
||||||
|
This event contains scaling geometry information
|
||||||
|
that is not in the geometry event. It may be sent after
|
||||||
|
binding the output object or if the output scale changes
|
||||||
|
later. If it is not sent, the client should assume a
|
||||||
|
scale of 1.
|
||||||
|
|
||||||
|
A scale larger than 1 means that the compositor will
|
||||||
|
automatically scale surface buffers by this amount
|
||||||
|
when rendering. This is used for high resolution
|
||||||
|
displays where applications rendering at the native
|
||||||
|
resolution would be too small to be legible.
|
||||||
|
|
||||||
|
It is intended that scaling aware clients track the
|
||||||
|
current output of a surface, and if it is on a scaled
|
||||||
|
output it should use wl_surface.set_buffer_scale with
|
||||||
|
the scale of the output. That way the compositor can
|
||||||
|
avoid scaling the surface, and the client can supply
|
||||||
|
a higher detail image.
|
||||||
|
</description>
|
||||||
|
<arg name="factor" type="fixed" summary="scaling factor of output"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="edid">
|
||||||
|
<description summary="advertise EDID data for the output">
|
||||||
|
The edid event encapsulates the EDID data for the outputdevice.
|
||||||
|
|
||||||
|
The event is sent when binding to the output object. The EDID
|
||||||
|
data may be empty, in which case this event is sent anyway.
|
||||||
|
If the EDID information is empty, you can fall back to the name
|
||||||
|
et al. properties of the outputdevice.
|
||||||
|
</description>
|
||||||
|
<arg name="raw" type="string" summary="base64-encoded EDID string"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="enabled">
|
||||||
|
<description summary="output is enabled or disabled">
|
||||||
|
The enabled event notifies whether this output is currently
|
||||||
|
enabled and used for displaying content by the server.
|
||||||
|
The event is sent when binding to the output object and
|
||||||
|
whenever later on an output changes its state by becoming
|
||||||
|
enabled or disabled.
|
||||||
|
</description>
|
||||||
|
<arg name="enabled" type="int" summary="output enabled state"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="uuid">
|
||||||
|
<description summary="A unique id for this outputdevice">
|
||||||
|
The uuid can be used to identify the output. It's controlled by
|
||||||
|
the server entirely. The server should make sure the uuid is
|
||||||
|
persistent across restarts. An empty uuid is considered invalid.
|
||||||
|
</description>
|
||||||
|
<arg name="uuid" type="string" summary="output devices ID"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="serial_number">
|
||||||
|
<description summary="Serial Number">
|
||||||
|
Serial ID of the monitor, sent on startup before the first done event.
|
||||||
|
</description>
|
||||||
|
<arg name="serialNumber" type="string"
|
||||||
|
summary="textual representation of serial number"/>
|
||||||
|
</event>
|
||||||
|
<event name="eisa_id">
|
||||||
|
<description summary="EISA ID">
|
||||||
|
EISA ID of the monitor, sent on startup before the first done event.
|
||||||
|
</description>
|
||||||
|
<arg name="eisaId" type="string"
|
||||||
|
summary="textual representation of EISA identifier"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<enum name="capability" bitfield="true">
|
||||||
|
<description summary="describes capabilities of the outputdevice">
|
||||||
|
Describes what capabilities this device has.
|
||||||
|
</description>
|
||||||
|
<entry name="overscan" value="0x1"
|
||||||
|
summary="if this output_device can use overscan"/>
|
||||||
|
<entry name="vrr" value="0x2"
|
||||||
|
summary="if this outputdevice supports variable refresh rate"/>
|
||||||
|
<entry name="rgb_range" value="0x4"
|
||||||
|
summary="if setting the rgb range is possible"/>
|
||||||
|
<entry name="high_dynamic_range" value="0x8" since="3"
|
||||||
|
summary="if this outputdevice supports high dynamic range"/>
|
||||||
|
<entry name="wide_color_gamut" value="0x10" since="3"
|
||||||
|
summary="if this outputdevice supports a wide color gamut"/>
|
||||||
|
<entry name="auto_rotate" value="0x20" since="4"
|
||||||
|
summary="if this outputdevice supports autorotation"/>
|
||||||
|
<entry name="icc_profile" value="0x40" since="5"
|
||||||
|
summary="if this outputdevice supports icc profiles"/>
|
||||||
|
<entry name="brightness" value="0x80" since="9"
|
||||||
|
summary="if this outputdevice supports the brightness setting"/>
|
||||||
|
<entry name="built_in_color" value="0x100" since="12"
|
||||||
|
summary="if this outputdevice supports the built-in color profile"/>
|
||||||
|
<entry name="ddc_ci" value="0x200" since="14"
|
||||||
|
summary="if this outputdevice supports DDC/CI"/>
|
||||||
|
<entry name="max_bits_per_color" value="0x400" since="15"
|
||||||
|
summary="if this outputdevice supports setting max bpc"/>
|
||||||
|
<entry name="edr" value="0x800" since="16"
|
||||||
|
summary="if this outputdevice supports EDR"/>
|
||||||
|
<entry name="sharpness" value="0x1000" since="17"
|
||||||
|
summary="if this outputdevice supports the sharpness setting"/>
|
||||||
|
<entry name="custom_modes" value="0x2000" since="18"
|
||||||
|
summary="if this outputdevice supports custom modes"/>
|
||||||
|
<entry name="auto_brightness" value = "0x4000" since="19"/>
|
||||||
|
<entry name="hdr_icc_profile" value="0x8000" since="22"
|
||||||
|
summary="if this outputdevice supports HDR ICC profiles"/>
|
||||||
|
<entry name="abm_level" value="0x10000" since="23"
|
||||||
|
summary="if this outputdevice supports the abm level setting"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<event name="capabilities">
|
||||||
|
<description summary="capability flags">
|
||||||
|
What capabilities this device has, sent on startup before the first
|
||||||
|
done event.
|
||||||
|
</description>
|
||||||
|
<arg name="flags" type="uint" enum="capability"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="overscan">
|
||||||
|
<description summary="overscan">
|
||||||
|
Overscan value of the monitor in percent, sent on startup before the
|
||||||
|
first done event.
|
||||||
|
</description>
|
||||||
|
<arg name="overscan" type="uint"
|
||||||
|
summary="amount of overscan of the monitor"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<enum name="vrr_policy">
|
||||||
|
<description summary="describes vrr policy">
|
||||||
|
Describes when the compositor may employ variable refresh rate
|
||||||
|
</description>
|
||||||
|
<entry name="never" value="0"/>
|
||||||
|
<entry name="always" value="1"/>
|
||||||
|
<entry name="automatic" value="2"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<event name="vrr_policy">
|
||||||
|
<description summary="Variable Refresh Rate Policy">
|
||||||
|
What policy the compositor will employ regarding its use of variable
|
||||||
|
refresh rate.
|
||||||
|
</description>
|
||||||
|
<arg name="vrr_policy" type="uint" enum="vrr_policy"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<enum name="rgb_range">
|
||||||
|
<description summary="describes RGB range policy">
|
||||||
|
Whether full or limited color range should be used
|
||||||
|
</description>
|
||||||
|
<entry name="automatic" value="0"/>
|
||||||
|
<entry name="full" value="1"/>
|
||||||
|
<entry name="limited" value="2"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<event name="rgb_range">
|
||||||
|
<description summary="RGB range">
|
||||||
|
What rgb range the compositor is using for this output
|
||||||
|
</description>
|
||||||
|
<arg name="rgb_range" type="uint" enum="rgb_range"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="name" since="2">
|
||||||
|
<description summary="Output's name">
|
||||||
|
Name of the output, it's useful to cross-reference to an zxdg_output_v1 and ultimately QScreen
|
||||||
|
</description>
|
||||||
|
<arg name="name" type="string"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="high_dynamic_range" since="3">
|
||||||
|
<description summary="if HDR is enabled">
|
||||||
|
Whether or not high dynamic range is enabled for this output
|
||||||
|
</description>
|
||||||
|
<arg name="hdr_enabled" type="uint" summary="1 if enabled, 0 if disabled"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="sdr_brightness" since="3">
|
||||||
|
<description summary="the brightness of sdr if hdr is enabled">
|
||||||
|
If high dynamic range is used, this value defines the brightness in nits for content
|
||||||
|
that's in standard dynamic range format. Note that while the value is in nits, that
|
||||||
|
doesn't necessarily translate to the same brightness on the screen.
|
||||||
|
</description>
|
||||||
|
<arg name="sdr_brightness" type="uint"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="wide_color_gamut" since="3">
|
||||||
|
<description summary="if WCG is enabled">
|
||||||
|
Whether or not the use of a wide color gamut is enabled for this output
|
||||||
|
</description>
|
||||||
|
<arg name="wcg_enabled" type="uint" summary="1 if enabled, 0 if disabled"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<enum name="auto_rotate_policy">
|
||||||
|
<description summary="describes when auto rotate should be used"/>
|
||||||
|
<entry name="never" value="0"/>
|
||||||
|
<entry name="in_tablet_mode" value="1"/>
|
||||||
|
<entry name="always" value="2"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<event name="auto_rotate_policy" since="4">
|
||||||
|
<description summary="describes when auto rotate is used"/>
|
||||||
|
<arg name="policy" type="uint" enum="auto_rotate_policy"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="icc_profile_path" since="5">
|
||||||
|
<description summary="describes the path to the ICC profile used in SDR mode"/>
|
||||||
|
<arg name="profile_path" type="string"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="brightness_metadata" since="6">
|
||||||
|
<description summary="metadata about the screen's brightness limits"/>
|
||||||
|
<arg name="max_peak_brightness" type="uint" summary="in nits"/>
|
||||||
|
<arg name="max_frame_average_brightness" type="uint" summary="in nits"/>
|
||||||
|
<arg name="min_brightness" type="uint" summary="in 0.0001 nits"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="brightness_overrides" since="6">
|
||||||
|
<description summary="overrides for the screen's brightness limits"/>
|
||||||
|
<arg name="max_peak_brightness" type="int" summary="-1 for no override, positive values are the brightness in nits"/>
|
||||||
|
<arg name="max_average_brightness" type="int" summary="-1 for no override, positive values are the brightness in nits"/>
|
||||||
|
<arg name="min_brightness" type="int" summary="-1 for no override, positive values are the brightness in 0.0001 nits"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="sdr_gamut_wideness" since="6">
|
||||||
|
<description summary="describes which gamut is assumed for sRGB applications">
|
||||||
|
This can be used to provide the colors users assume sRGB applications should have based on the
|
||||||
|
default experience on many modern sRGB screens.
|
||||||
|
</description>
|
||||||
|
<arg name="gamut_wideness" type="uint" summary="0 means rec.709 primaries, 10000 means native primaries"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<enum name="color_profile_source" since="7">
|
||||||
|
<description summary="which source the compositor should use for the color profile on an output"/>
|
||||||
|
<entry name="sRGB" value="0"/>
|
||||||
|
<entry name="ICC" value="1"/>
|
||||||
|
<entry name="EDID" value="2"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<event name="color_profile_source" since="7">
|
||||||
|
<description summary="describes which source the compositor uses for the color profile on an output in SDR mode"/>
|
||||||
|
<arg name="source" type="uint" enum="color_profile_source"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="brightness" since="8">
|
||||||
|
<description summary="brightness multiplier">
|
||||||
|
This is the brightness modifier of the output. It doesn't specify
|
||||||
|
any absolute values, but is merely a multiplier on top of other
|
||||||
|
brightness values, like sdr_brightness and brightness_metadata.
|
||||||
|
0 is the minimum brightness (not completely dark) and 10000 is
|
||||||
|
the maximum brightness.
|
||||||
|
This is currently only supported / meaningful while HDR is active.
|
||||||
|
</description>
|
||||||
|
<arg name="brightness" type="uint" summary="brightness in 0-10000"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<enum name="color_power_tradeoff">
|
||||||
|
<description summary="tradeoff between power and accuracy">
|
||||||
|
The compositor can do a lot of things that trade between
|
||||||
|
performance, power and color accuracy. This setting describes
|
||||||
|
a high level preference from the user about in which direction
|
||||||
|
that tradeoff should be made.
|
||||||
|
</description>
|
||||||
|
<entry name="efficiency" value="0" summary="prefer efficiency and performance"/>
|
||||||
|
<entry name="accuracy" value="1" summary="prefer accuracy"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<event name="color_power_tradeoff" since="10">
|
||||||
|
<description summary="the preferred color/power tradeoff"/>
|
||||||
|
<arg name="preference" type="uint" enum="color_power_tradeoff"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="dimming" since="11">
|
||||||
|
<description summary="dimming multiplier">
|
||||||
|
This is the dimming multiplier of the output. This is similar to
|
||||||
|
the brightness setting, except it's meant to be a temporary setting
|
||||||
|
only, not persistent and may be implemented differently depending
|
||||||
|
on the display.
|
||||||
|
0 is the minimum dimming factor (not completely dark) and 10000
|
||||||
|
means the output is not dimmed.
|
||||||
|
</description>
|
||||||
|
<arg name="multiplier" type="uint" summary="multiplier in 0-10000"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="replication_source" since="13">
|
||||||
|
<description summary="source output for mirroring"/>
|
||||||
|
<arg name="source" type="string" summary="uuid of the source output"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="ddc_ci_allowed" since="14">
|
||||||
|
<description summary="if DDC/CI should be used to control brightness etc.">
|
||||||
|
If the ddc_ci capability is present, this determines if settings
|
||||||
|
such as brightness, contrast or others should be set using DDC/CI.
|
||||||
|
</description>
|
||||||
|
<arg name="allowed" type="uint" summary="1 if allowed, 0 if disabled"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="max_bits_per_color" since="15">
|
||||||
|
<description summary="override max bpc">
|
||||||
|
This limits the amount of bits per color that are sent to the display.
|
||||||
|
</description>
|
||||||
|
<arg name="max_bpc" type="uint" summary="0 for the default / automatic"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="max_bits_per_color_range" since="15">
|
||||||
|
<description summary="range of max bits per color value"/>
|
||||||
|
<arg name="min_value" type="uint" summary="the minimum supported by the driver"/>
|
||||||
|
<arg name="max_value" type="uint" summary="the maximum supported by the driver"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="automatic_max_bits_per_color_limit" since="15">
|
||||||
|
<description summary="if and to what value automatic max bpc is limited"/>
|
||||||
|
<arg name="max_bpc_limit" type="uint"
|
||||||
|
summary="which value automatic bpc gets limited to. 0 if not limited"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<enum name="edr_policy" since="16">
|
||||||
|
<description summary="when the compositor may make use of EDR"/>
|
||||||
|
<entry name="never" value="0"/>
|
||||||
|
<entry name="always" value="1"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<event name="edr_policy" since="16">
|
||||||
|
<description summary="when the compositor may apply EDR">
|
||||||
|
When EDR is enabled, the compositor may increase the backlight beyond
|
||||||
|
the user-specified setting, in order to present HDR content on displays
|
||||||
|
without native HDR support.
|
||||||
|
This will usually result in better visuals, but also increases battery
|
||||||
|
usage.
|
||||||
|
</description>
|
||||||
|
<arg name="policy" type="uint" enum="edr_policy"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="sharpness" since="17">
|
||||||
|
<description summary="sharpness strength">
|
||||||
|
This is the sharpness modifier of the output.
|
||||||
|
0 is sharpness disabled and 10000 is the maximum sharpness
|
||||||
|
</description>
|
||||||
|
<arg name="sharpness" type="uint" summary="sharpness in 0-10000"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="priority" since="18">
|
||||||
|
<description summary="output priority">
|
||||||
|
Describes the position of the output in the output order list,
|
||||||
|
with lower values being earlier in the list. There's no specific
|
||||||
|
value the list has to start at, this value is only used in sorting
|
||||||
|
outputs.
|
||||||
|
|
||||||
|
Note that the output order protocol is not sufficient for this,
|
||||||
|
as an output may not be in the output order if it's disabled or
|
||||||
|
mirroring another screen.
|
||||||
|
</description>
|
||||||
|
<arg name="priority" type="uint" summary="priority"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="auto_brightness" since="20">
|
||||||
|
<description summary="whether or not automatic brightness is enabled"/>
|
||||||
|
<arg name="enabled" type="uint" summary="1 for enabled, 0 for disabled"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<request name="release" type="destructor" since="21">
|
||||||
|
<description summary="destroy the output device">
|
||||||
|
This notifies the compositor that the client no longer wishes to use
|
||||||
|
the kde_output_device_v2 object.
|
||||||
|
</description>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<event name="removed" since="21">
|
||||||
|
<description summary="the output has been removed">
|
||||||
|
This event is sent when the output device is disconnected and no new
|
||||||
|
updates will be sent. The client should call the kde_output_device_v2.release
|
||||||
|
request after receiving this event.
|
||||||
|
</description>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="hdr_icc_profile_path" since="22">
|
||||||
|
<description summary="describes the path to the ICC profile used in HDR mode"/>
|
||||||
|
<arg name="profile_path" type="string"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="hdr_color_profile_source" since="22">
|
||||||
|
<description summary="describes which source the compositor uses for the color profile on an output in HDR mode"/>
|
||||||
|
<arg name="source" type="uint" enum="color_profile_source"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="abm_level" since="23">
|
||||||
|
<description summary="allowed level of adaptive backlight modulation">
|
||||||
|
Adaptive backlight modulation is a feature that reduces the backlight
|
||||||
|
and increases contrast of colors on the screen to improve power usage.
|
||||||
|
</description>
|
||||||
|
<arg name="level" type="uint" summary="0 is off, 4 is the maximum level"/>
|
||||||
|
</event>
|
||||||
|
</interface>
|
||||||
|
|
||||||
|
<interface name="kde_output_device_mode_v2" version="24">
|
||||||
|
<description summary="output mode">
|
||||||
|
This object describes an output mode.
|
||||||
|
|
||||||
|
Some heads don't support output modes, in which case modes won't be
|
||||||
|
advertised.
|
||||||
|
|
||||||
|
Properties sent via this interface are applied atomically via the
|
||||||
|
kde_output_device.done event. No guarantees are made regarding the order
|
||||||
|
in which properties are sent.
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<event name="size">
|
||||||
|
<description summary="mode size">
|
||||||
|
This event describes the mode size. The size is given in physical
|
||||||
|
hardware units of the output device. This is not necessarily the same as
|
||||||
|
the output size in the global compositor space. For instance, the output
|
||||||
|
may be scaled or transformed.
|
||||||
|
</description>
|
||||||
|
<arg name="width" type="int" summary="width of the mode in hardware units"/>
|
||||||
|
<arg name="height" type="int" summary="height of the mode in hardware units"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="refresh">
|
||||||
|
<description summary="mode refresh rate">
|
||||||
|
This event describes the mode's fixed vertical refresh rate. It is only
|
||||||
|
sent if the mode has a fixed refresh rate.
|
||||||
|
</description>
|
||||||
|
<arg name="refresh" type="int" summary="vertical refresh rate in mHz"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="preferred">
|
||||||
|
<description summary="mode is preferred">
|
||||||
|
This event advertises this mode as preferred.
|
||||||
|
</description>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="removed">
|
||||||
|
<description summary="the mode has been destroyed">
|
||||||
|
The compositor will destroy the object immediately after sending this
|
||||||
|
event, so it will become invalid and the client should release any
|
||||||
|
resources associated with it.
|
||||||
|
</description>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<enum name="flags">
|
||||||
|
<description summary="mode flags"/>
|
||||||
|
<entry name="custom" value="0x1"/>
|
||||||
|
<entry name="reduced_blanking" value="0x2" deprecated-since="24"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<event name="flags" since="19">
|
||||||
|
<description summary="mode flags">
|
||||||
|
This event describes the mode's flags.
|
||||||
|
</description>
|
||||||
|
<arg name="flags" type="uint" enum="flags"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="cvt" since="24">
|
||||||
|
<description summary="cvt timings">
|
||||||
|
This event describes the CVT timings associated with the mode.
|
||||||
|
|
||||||
|
If an output mode has no CVT timings, this event will not be sent.
|
||||||
|
</description>
|
||||||
|
<arg name="dot_clock" type="uint" summary="pixel clock in kHz"/>
|
||||||
|
<arg name="hdisplay" type="uint" summary="horizontal display size"/>
|
||||||
|
<arg name="hsync_start" type="uint" summary="horizontal sync start"/>
|
||||||
|
<arg name="hsync_end" type="uint" summary="horizontal sync end"/>
|
||||||
|
<arg name="htotal" type="uint" summary="horizontal total size"/>
|
||||||
|
<arg name="hskew" type="uint" summary="horizontal skew"/>
|
||||||
|
<arg name="vdisplay" type="uint" summary="vertical display size"/>
|
||||||
|
<arg name="vsync_start" type="uint" summary="vertical sync start"/>
|
||||||
|
<arg name="vsync_end" type="uint" summary="vertical sync end"/>
|
||||||
|
<arg name="vtotal" type="uint" summary="vertical total size"/>
|
||||||
|
<arg name="vscan" type="uint" summary="vertical scan"/>
|
||||||
|
<arg name="flags" type="uint" summary="flags, see DRM_MODE_FLAG_*"/>
|
||||||
|
</event>
|
||||||
|
</interface>
|
||||||
|
|
||||||
|
</protocol>
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<protocol name="kde_output_management_v2">
|
||||||
|
<copyright><![CDATA[
|
||||||
|
SPDX-FileCopyrightText: 2008-2011 Kristian Høgsberg
|
||||||
|
SPDX-FileCopyrightText: 2010-2011 Intel Corporation
|
||||||
|
SPDX-FileCopyrightText: 2012-2013 Collabora, Ltd.
|
||||||
|
SPDX-FileCopyrightText: 2015 Sebastian Kügler <sebas@kde.org>
|
||||||
|
SPDX-FileCopyrightText: 2021 Méven Car <meven.car@enioka.com>
|
||||||
|
SPDX-FileCopyrightText: 2023 Xaver Hugl <xaver.hugl@kde.org>
|
||||||
|
|
||||||
|
SPDX-License-Identifier: MIT-CMU
|
||||||
|
]]></copyright>
|
||||||
|
|
||||||
|
<interface name="kde_output_management_v2" version="22">
|
||||||
|
<description summary="configuration of server outputs through clients">
|
||||||
|
This interface enables clients to set properties of output devices for screen
|
||||||
|
configuration purposes via the server. To this end output devices are referenced
|
||||||
|
by global kde_output_device_v2 objects.
|
||||||
|
|
||||||
|
outputmanagement (wl_global)
|
||||||
|
--------------------------
|
||||||
|
request:
|
||||||
|
* create_configuration -> outputconfiguration (wl_resource)
|
||||||
|
|
||||||
|
outputconfiguration (wl_resource)
|
||||||
|
--------------------------
|
||||||
|
requests:
|
||||||
|
* enable(outputdevice, bool)
|
||||||
|
* mode(outputdevice, mode)
|
||||||
|
* transformation(outputdevice, flag)
|
||||||
|
* position(outputdevice, x, y)
|
||||||
|
* apply
|
||||||
|
|
||||||
|
events:
|
||||||
|
* applied
|
||||||
|
* failed
|
||||||
|
|
||||||
|
The server registers one outputmanagement object as a global object. In order
|
||||||
|
to configure outputs a client requests create_configuration, which provides a
|
||||||
|
resource referencing an outputconfiguration for one-time configuration. That
|
||||||
|
way the server knows which requests belong together and can group them by that.
|
||||||
|
|
||||||
|
On the outputconfiguration object the client calls for each output whether the
|
||||||
|
output should be enabled, which mode should be set (by referencing the mode from
|
||||||
|
the list of announced modes) and the output's global position. Once all outputs
|
||||||
|
are configured that way, the client calls apply.
|
||||||
|
At that point and not earlier the server should try to apply the configuration.
|
||||||
|
If this succeeds the server emits the applied signal, otherwise the failed
|
||||||
|
signal, such that the configuring client is noticed about the success of its
|
||||||
|
configuration request.
|
||||||
|
|
||||||
|
Through this design the interface enables atomic output configuration changes if
|
||||||
|
internally supported by the server.
|
||||||
|
|
||||||
|
Warning! The protocol described in this file is a desktop environment implementation
|
||||||
|
detail. Regular clients must not use this protocol. Backward incompatible
|
||||||
|
changes may be added without bumping the major version of the extension.
|
||||||
|
</description>
|
||||||
|
<request name="create_configuration">
|
||||||
|
<description summary="provide outputconfiguration object for configuring outputs">
|
||||||
|
Request an outputconfiguration object through which the client can configure
|
||||||
|
output devices.
|
||||||
|
</description>
|
||||||
|
<arg name="id" type="new_id" interface="kde_output_configuration_v2"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="create_mode_list">
|
||||||
|
<description summary="create a list of custom modes">
|
||||||
|
For details, see the description of kde_mode_list_v2 and
|
||||||
|
kde_output_configuration_v2.set_custom_modes.
|
||||||
|
</description>
|
||||||
|
<arg name="id" type="new_id" interface="kde_mode_list_v2"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
</interface>
|
||||||
|
|
||||||
|
<interface name="kde_output_configuration_v2" version="22">
|
||||||
|
<description summary="configure single output devices">
|
||||||
|
outputconfiguration is a client-specific resource that can be used to ask
|
||||||
|
the server to apply changes to available output devices.
|
||||||
|
|
||||||
|
The client receives a list of output devices from the registry. When it wants
|
||||||
|
to apply new settings, it creates a configuration object from the
|
||||||
|
outputmanagement global, writes changes through this object's enable, scale,
|
||||||
|
transform and mode calls. It then asks the server to apply these settings in
|
||||||
|
an atomic fashion, for example through Linux' DRM interface.
|
||||||
|
|
||||||
|
The server signals back whether the new settings have applied successfully
|
||||||
|
or failed to apply. outputdevice objects are updated after the changes have been
|
||||||
|
applied to the hardware and before the server side sends the applied event.
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<enum name="error">
|
||||||
|
<description summary="kde_output_configuration_v2 error values">
|
||||||
|
These error can be emitted in response to kde_output_configuration_v2 requests.
|
||||||
|
</description>
|
||||||
|
<entry name="already_applied" value="0" summary="the config is already applied"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<request name="enable">
|
||||||
|
<description summary="enable or disable an output">
|
||||||
|
Mark the output as enabled or disabled.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice to be en- or disabled"/>
|
||||||
|
<arg name="enable" type="int" summary="1 to enable or 0 to disable this output"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="mode">
|
||||||
|
<description summary="switch output-device to mode">
|
||||||
|
Sets the mode for a given output.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this mode change applies to"/>
|
||||||
|
<arg name="mode" type="object" interface="kde_output_device_mode_v2" summary="the mode to apply"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="transform">
|
||||||
|
<description summary="transform output-device">
|
||||||
|
Sets the transformation for a given output.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this transformation change applies to"/>
|
||||||
|
<arg name="transform" type="int" summary="transform enum"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="position">
|
||||||
|
<description summary="position output in global space">
|
||||||
|
Sets the position for this output device. (x,y) describe the top-left corner
|
||||||
|
of the output in global space, whereby the origin (0,0) of the global space
|
||||||
|
has to be aligned with the top-left corner of the most left and in case this
|
||||||
|
does not define a single one the top output.
|
||||||
|
|
||||||
|
There may be no gaps or overlaps between outputs, i.e. the outputs are
|
||||||
|
stacked horizontally, vertically, or both on each other.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this position applies to"/>
|
||||||
|
<arg name="x" type="int" summary="position on the x-axis"/>
|
||||||
|
<arg name="y" type="int" summary="position on the y-axis"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="scale">
|
||||||
|
<description summary="set scaling factor of this output">
|
||||||
|
Sets the scaling factor for this output device.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this scale change applies to"/>
|
||||||
|
<arg name="scale" type="fixed" summary="scaling factor"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="apply">
|
||||||
|
<description summary="apply configuration changes to all output devices">
|
||||||
|
Asks the server to apply property changes requested through this outputconfiguration
|
||||||
|
object to all outputs on the server side.
|
||||||
|
|
||||||
|
The output configuration can be applied only once. The already_applied protocol error
|
||||||
|
will be posted if the apply request is called the second time.
|
||||||
|
</description>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<event name="applied">
|
||||||
|
<description summary="configuration changes have been applied">
|
||||||
|
Sent after the server has successfully applied the changes.
|
||||||
|
.
|
||||||
|
</description>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<event name="failed">
|
||||||
|
<description summary="configuration changes failed to apply">
|
||||||
|
Sent if the server rejects the changes or failed to apply them.
|
||||||
|
</description>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<request name="destroy" type="destructor">
|
||||||
|
<description summary="release the outputconfiguration object"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="overscan">
|
||||||
|
<description summary="set overscan value">
|
||||||
|
Set the overscan value of this output device with a value in percent.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice overscan applies to"/>
|
||||||
|
<arg name="overscan" type="uint" summary="overscan value"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<enum name="vrr_policy">
|
||||||
|
<description summary="describes vrr policy">
|
||||||
|
Describes when the compositor may employ variable refresh rate
|
||||||
|
</description>
|
||||||
|
<entry name="never" value="0"/>
|
||||||
|
<entry name="always" value="1"/>
|
||||||
|
<entry name="automatic" value="2"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<request name="set_vrr_policy">
|
||||||
|
<description summary="set the VRR policy">
|
||||||
|
Set what policy the compositor should employ regarding its use of
|
||||||
|
variable refresh rate.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this VRR policy applies to"/>
|
||||||
|
<arg name="policy" type="uint" enum="vrr_policy" summary="the vrr policy to apply"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<enum name="rgb_range">
|
||||||
|
<description summary="describes RGB range policy">
|
||||||
|
Whether this output should use full or limited rgb.
|
||||||
|
</description>
|
||||||
|
<entry name="automatic" value="0"/>
|
||||||
|
<entry name="full" value="1"/>
|
||||||
|
<entry name="limited" value="2"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<request name="set_rgb_range">
|
||||||
|
<description summary="RGB range">
|
||||||
|
Whether full or limited color range should be used
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice the rgb range applies to"/>
|
||||||
|
<arg name="rgb_range" type="uint" enum="rgb_range"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_primary_output" since="2">
|
||||||
|
<description summary="Select which primary output to use" />
|
||||||
|
<arg name="output" type="object" interface="kde_output_device_v2" allow-null="false"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_priority" since="3">
|
||||||
|
<description summary="Set the order of outputs">
|
||||||
|
Set the position of the output in the output order list, with lower values
|
||||||
|
being earlier in the list. There's no specific value the list has to start
|
||||||
|
at, this value is only used in sorting outputs.
|
||||||
|
|
||||||
|
The order of outputs can be used to assign desktop environment components
|
||||||
|
to a specific screen, see kde_output_order_v1 and kde-output-device-v2 for
|
||||||
|
details. Note that for consistent behavior, the priority value needs to be
|
||||||
|
unique among all enabled outputs.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice the index applies to" />
|
||||||
|
<arg name="priority" type="uint" summary="the priority of the output" />
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_high_dynamic_range" since="4">
|
||||||
|
<description summary="change if HDR should be enabled">
|
||||||
|
Sets whether or not the output should be set to HDR mode.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="enable_hdr" type="uint" summary="1 to enable, 0 to disable hdr"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_sdr_brightness" since="4">
|
||||||
|
<description summary="set the brightness for sdr content">
|
||||||
|
Sets the brightness of standard dynamic range content in nits. Only has an effect while the output is in HDR mode.
|
||||||
|
Note that while the value is in nits, that doesn't necessarily translate to the same brightness on the screen.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="sdr_brightness" type="uint"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_wide_color_gamut" since="4">
|
||||||
|
<description summary="change if a wide color gamut should be used">
|
||||||
|
Whether or not the output should use a wide color gamut
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="enable_wcg" type="uint" summary="1 to enable, 0 to disable wcg"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<enum name="auto_rotate_policy">
|
||||||
|
<description summary="describes when auto rotate should be used"/>
|
||||||
|
<entry name="never" value="0"/>
|
||||||
|
<entry name="in_tablet_mode" value="1"/>
|
||||||
|
<entry name="always" value="2"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<request name="set_auto_rotate_policy" since="5">
|
||||||
|
<description summary="change when auto rotate should be used"/>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="policy" type="uint" enum="auto_rotate_policy"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_icc_profile_path" since="6">
|
||||||
|
<description summary="change the used icc profile for SDR mode"/>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="profile_path" type="string"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_brightness_overrides" since="7">
|
||||||
|
<description summary="override metadata about the screen's brightness limits"/>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="max_peak_brightness" type="int" summary="-1 for not overriding, or positive values in nits"/>
|
||||||
|
<arg name="max_frame_average_brightness" type="int" summary="-1 for not overriding, or positive values in nits"/>
|
||||||
|
<arg name="min_brightness" type="int" summary="-1 for not overriding, or positive values in 0.0001 nits"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_sdr_gamut_wideness" since="7">
|
||||||
|
<description summary="describes which gamut is assumed for sRGB applications">
|
||||||
|
This can be used to provide the colors users assume sRGB applications should have based on the
|
||||||
|
default experience on many modern sRGB screens.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="gamut_wideness" type="uint" summary="0 means rec.709 primaries, 10000 means native primaries"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<enum name="color_profile_source" since="7">
|
||||||
|
<description summary="which source the compositor should use for the color profile on an output"/>
|
||||||
|
<entry name="sRGB" value="0"/>
|
||||||
|
<entry name="ICC" value="1"/>
|
||||||
|
<entry name="EDID" value="2"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<request name="set_color_profile_source" since="8">
|
||||||
|
<description summary="which source the compositor should use for the color profile on an output in SDR mode"/>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="color_profile_source" type="uint" enum="color_profile_source" summary="the color profile source"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_brightness" since="9">
|
||||||
|
<description summary="brightness multiplier">
|
||||||
|
Set the brightness modifier of the output. It doesn't specify
|
||||||
|
any absolute values, but is merely a multiplier on top of other
|
||||||
|
brightness values, like sdr_brightness and brightness_metadata.
|
||||||
|
0 is the minimum brightness (not completely dark) and 10000 is
|
||||||
|
the maximum brightness.
|
||||||
|
This is supported while HDR is active in versions 8 and below,
|
||||||
|
or when the device supports the "brightness" capability in
|
||||||
|
versions 9 and above.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="brightness" type="uint" summary="brightness in 0-10000"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<enum name="color_power_tradeoff">
|
||||||
|
<description summary="tradeoff between power and accuracy">
|
||||||
|
The compositor can do a lot of things that trade between
|
||||||
|
performance, power and color accuracy. This setting describes
|
||||||
|
a high level preference from the user about in which direction
|
||||||
|
that tradeoff should be made.
|
||||||
|
</description>
|
||||||
|
<entry name="efficiency" value="0" summary="prefer efficiency and performance"/>
|
||||||
|
<entry name="accuracy" value="1" summary="prefer accuracy"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<request name="set_color_power_tradeoff" since="10">
|
||||||
|
<description summary="set the preferred color/power tradeoff"/>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="preference" type="uint" enum="color_power_tradeoff"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_dimming" since="11">
|
||||||
|
<description summary="dimming multiplier">
|
||||||
|
Set the dimming multiplier of the output. This is similar to the
|
||||||
|
brightness setting, except it's meant to be a temporary setting
|
||||||
|
only, not persistent and may be implemented differently depending
|
||||||
|
on the display.
|
||||||
|
0 is the minimum dimming factor (not completely dark) and 10000
|
||||||
|
means the output is not dimmed.
|
||||||
|
|
||||||
|
This is supported only when the "brightness" capability is
|
||||||
|
also supported.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="multiplier" type="uint" summary="multiplier in 0-10000"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<event name="failure_reason" since="12">
|
||||||
|
<description summary="reason for failure">
|
||||||
|
Describes why applying the output configuration failed. Is only
|
||||||
|
sent before the failure event.
|
||||||
|
</description>
|
||||||
|
<arg name="reason" type="string" summary="reason for failure"/>
|
||||||
|
</event>
|
||||||
|
|
||||||
|
<request name="set_replication_source" since="13">
|
||||||
|
<description summary="source output for mirroring">
|
||||||
|
Set the source output that the outputdevice should mirror its
|
||||||
|
viewport from.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="source" type="string" summary="uuid of the source output"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_ddc_ci_allowed" since="14">
|
||||||
|
<description summary="if DDC/CI should be used to control brightness etc."/>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="allowed" type="uint" summary="1 if allowed, 0 if disabled"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_max_bits_per_color" since="15">
|
||||||
|
<description summary="override the max bpc">
|
||||||
|
This limits the amount of bits per color that are sent to the display.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="max_bpc" type="uint" summary="0 for the default / automatic"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<enum name="edr_policy" since="16">
|
||||||
|
<description summary="when the compositor may make use of EDR"/>
|
||||||
|
<entry name="never" value="0"/>
|
||||||
|
<entry name="always" value="1"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<request name="set_edr_policy" since="16">
|
||||||
|
<description summary="set when the compositor may apply EDR">
|
||||||
|
When EDR is enabled, the compositor may increase the backlight beyond
|
||||||
|
the user-specified setting, in order to present HDR content on displays
|
||||||
|
without native HDR support.
|
||||||
|
This will usually result in better visuals, but also increases battery
|
||||||
|
usage.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="policy" type="uint" enum="edr_policy"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_sharpness" since="17">
|
||||||
|
<description summary="sharpness strength">
|
||||||
|
This is the sharpness modifier of the output.
|
||||||
|
0 is sharpness disabled and 10000 is the maximum sharpness
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="sharpness" type="uint" summary="sharpness in 0-10000"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_custom_modes" since="18">
|
||||||
|
<description summary="set the custom mode list">
|
||||||
|
Set the list of custom modes for this output. The compositor
|
||||||
|
will in response generate the requested modes and add them to
|
||||||
|
the output (or delete ones no longer in the list).
|
||||||
|
This can be useful for overclocking displays, or for working
|
||||||
|
around broken EDIDs.
|
||||||
|
Note that there is no guarantee for any custom mode to
|
||||||
|
actually work, or even to leave the display undamaged (in the
|
||||||
|
case of CRTs). It's entirely the responsibility of the user
|
||||||
|
to ensure each added mode is the right one for their display.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="modes" type="object" interface="kde_mode_list_v2"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_auto_brightness" since="19">
|
||||||
|
<description summary="whether or not automatic brightness is enabled"/>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="enabled" type="uint" summary="1 for enabled, 0 for disabled"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_hdr_icc_profile_path" since="20">
|
||||||
|
<description summary="change the used icc profile for HDR mode"/>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="profile_path" type="string"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_hdr_color_profile_source" since="20">
|
||||||
|
<description summary="which source the compositor should use for the color profile on an output in HDR mode"/>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="color_profile_source" type="uint" enum="color_profile_source" summary="the color profile source"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_abm_level" since="21">
|
||||||
|
<description summary="set the allowed level of adaptive backlight modulation">
|
||||||
|
Adaptive backlight modulation is a feature that reduces the backlight
|
||||||
|
and increases contrast of colors on the screen to improve power usage.
|
||||||
|
</description>
|
||||||
|
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||||
|
<arg name="level" type="uint" summary="0 is off, 4 is the maximum level"/>
|
||||||
|
</request>
|
||||||
|
</interface>
|
||||||
|
|
||||||
|
<interface name="kde_mode_list_v2" version="22">
|
||||||
|
<description summary="a list of custom modes">
|
||||||
|
This list is populated by first setting each relevant property,
|
||||||
|
and then calling add_mode to add a mode with these properties.
|
||||||
|
One would for example call
|
||||||
|
- set_resolution
|
||||||
|
- set_refresh_rate
|
||||||
|
- set_reduced_blanking
|
||||||
|
- add_mode
|
||||||
|
|
||||||
|
add_mode does not reset the properties that were previously set,
|
||||||
|
they are valid until the object is destroyed.
|
||||||
|
The compositor may additionally have sensible defaults for some
|
||||||
|
properties like reduced_blanking, but for consistent results,
|
||||||
|
it's best to always set each known property every time.
|
||||||
|
|
||||||
|
One can also specify custom modes with CVT timings, for example
|
||||||
|
- add_cvt
|
||||||
|
- add_cvt
|
||||||
|
|
||||||
|
The parameters resolution and refresh rate are required, if they
|
||||||
|
are not set, the missing_parameters error will be emitted.
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<enum name="error">
|
||||||
|
<description summary="kde_mode_list_v2 error values">
|
||||||
|
These errors can be emitted in response to add_mode requests.
|
||||||
|
</description>
|
||||||
|
<entry name="missing_parameters" value="0" summary="a required parameter wasn't set"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<request name="destroy" type="destructor">
|
||||||
|
<description summary="destroy the mode list object"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="add_mode">
|
||||||
|
<description summary="Add the current mode configuration to the list"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_resolution">
|
||||||
|
<arg name="width" type="uint"/>
|
||||||
|
<arg name="height" type="uint"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_refresh_rate">
|
||||||
|
<arg name="rate" type="uint" summary="in milliHz"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="set_reduced_blanking">
|
||||||
|
<description summary="whether or not the mode should have reduced blanking">
|
||||||
|
Reduced blanking is an optimization that can reduce bandwidth / timing
|
||||||
|
requirements for a display mode by reducing the time vblank takes.
|
||||||
|
As not all displays support it, it may be desired to still turn it off
|
||||||
|
though (like with CRTs, where full blanking is required).
|
||||||
|
</description>
|
||||||
|
<arg name="reduced" type="uint"
|
||||||
|
summary="1 for reduced blanking, 0 for normal vblank duration"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
<request name="add_cvt" since="22">
|
||||||
|
<description summary="new mode with CVT timings">
|
||||||
|
Adds a new mode with the specified CVT timings.
|
||||||
|
</description>
|
||||||
|
<arg name="dot_clock" type="uint" summary="pixel clock in kHz"/>
|
||||||
|
<arg name="hdisplay" type="uint" summary="horizontal display size"/>
|
||||||
|
<arg name="hsync_start" type="uint" summary="horizontal sync start"/>
|
||||||
|
<arg name="hsync_end" type="uint" summary="horizontal sync end"/>
|
||||||
|
<arg name="htotal" type="uint" summary="horizontal total size"/>
|
||||||
|
<arg name="hskew" type="uint" summary="horizontal skew"/>
|
||||||
|
<arg name="vdisplay" type="uint" summary="vertical display size"/>
|
||||||
|
<arg name="vsync_start" type="uint" summary="vertical sync start"/>
|
||||||
|
<arg name="vsync_end" type="uint" summary="vertical sync end"/>
|
||||||
|
<arg name="vtotal" type="uint" summary="vertical total size"/>
|
||||||
|
<arg name="vscan" type="uint" summary="vertical scan"/>
|
||||||
|
<arg name="flags" type="uint" summary="flags, see DRM_MODE_FLAG_*"/>
|
||||||
|
</request>
|
||||||
|
|
||||||
|
</interface>
|
||||||
|
</protocol>
|
||||||
@@ -445,6 +445,13 @@ mod hyprland;
|
|||||||
#[path = "vdisplay/linux/kwin.rs"]
|
#[path = "vdisplay/linux/kwin.rs"]
|
||||||
mod kwin;
|
mod kwin;
|
||||||
|
|
||||||
|
// In-process KDE output management (kde_output_management_v2) — the topology path that used to shell
|
||||||
|
// out to `kscreen-doctor`, driven over the compositor's own Wayland instead so it can't be wedged by
|
||||||
|
// a stuck libkscreen/kscreen-KDED backend. Consumed by `kwin` (best-effort, with kscreen fallback).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "vdisplay/linux/kwin_output_mgmt.rs"]
|
||||||
|
mod kwin_output_mgmt;
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "vdisplay/windows/manager.rs"]
|
#[path = "vdisplay/windows/manager.rs"]
|
||||||
pub mod manager;
|
pub mod manager;
|
||||||
|
|||||||
@@ -61,6 +61,32 @@ static MANAGED_SESSION: std::sync::Mutex<Option<SessionState>> = std::sync::Mute
|
|||||||
/// (single-instance), so [`schedule_restore_tv_session`] can restart them when the client disconnects.
|
/// (single-instance), so [`schedule_restore_tv_session`] can restart them when the client disconnects.
|
||||||
static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/// The display-manager unit we stopped for the takeover on a mask-fragile DM flavor (Nobara's
|
||||||
|
/// `plasmalogin` — see [`dm_survives_masked_unit`]), so the restore brings the box back via
|
||||||
|
/// `reset-failed` + `restart` of the DM instead of a `--user start` of the gamescope unit (which
|
||||||
|
/// cannot work there: without a DM login session there is no seat, so gamescope never gets DRM
|
||||||
|
/// master — live-proven on the Nobara repro VM 2026-07-24).
|
||||||
|
static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
/// mtime of the `steamos-session-select` sentinel at managed-session launch — the baseline the
|
||||||
|
/// in-stream "Switch to Desktop" detector compares against. Steam's session-select script writes
|
||||||
|
/// `~/.config/steamos-session-select` unconditionally in its USER pass, before any of its
|
||||||
|
/// display-manager checks — so it advances even under a DM-stop takeover, where the script's
|
||||||
|
/// config-rewrite tail is a silent no-op (every write branch is gated on the DM *running*;
|
||||||
|
/// diagnosed live on the Nobara repro VM 2026-07-24). An advanced mtime after a capture loss is
|
||||||
|
/// therefore the one durable trace of the user's switch request.
|
||||||
|
static SESSION_SELECT_BASELINE: std::sync::Mutex<Option<std::time::SystemTime>> =
|
||||||
|
std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
/// When [`honor_session_select_switch`] last ran. While recent, a managed (re)launch is refused —
|
||||||
|
/// the rebuild loop would otherwise race the booting desktop back into game mode (gamescope+Steam
|
||||||
|
/// come up faster than KWin, and a delivering managed pipeline ends the rebuild's re-detection).
|
||||||
|
static SWITCH_HONORED_AT: std::sync::Mutex<Option<Instant>> = std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
/// How long after honoring an in-stream desktop switch the managed path refuses to relaunch,
|
||||||
|
/// giving the DM's desktop session time to come up so re-detection follows it instead.
|
||||||
|
const SWITCH_HONOR_GRACE: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
/// A pending debounced TV-session restore: the instant [`do_restore_tv_session`] should fire after
|
/// 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
|
/// 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
|
/// managed session), so we never stop+relaunch gamescope per connect — that per-connect teardown is
|
||||||
@@ -115,6 +141,10 @@ struct TakeoverState {
|
|||||||
stopped_autologin: Vec<String>,
|
stopped_autologin: Vec<String>,
|
||||||
/// Whether we took over SteamOS's `gamescope-session.target` (restore = remove drop-in + restart).
|
/// Whether we took over SteamOS's `gamescope-session.target` (restore = remove drop-in + restart).
|
||||||
steamos: bool,
|
steamos: bool,
|
||||||
|
/// The display-manager unit we stopped on a mask-fragile DM flavor (restore = `reset-failed` +
|
||||||
|
/// `restart` of the DM). `default` so takeover files from older hosts still parse.
|
||||||
|
#[serde(default)]
|
||||||
|
stopped_dm: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path of the persisted [`TakeoverState`], under `$XDG_RUNTIME_DIR` (per-user, 0700, tmpfs — cleared
|
/// Path of the persisted [`TakeoverState`], under `$XDG_RUNTIME_DIR` (per-user, 0700, tmpfs — cleared
|
||||||
@@ -133,8 +163,9 @@ fn persist_takeover() {
|
|||||||
.unwrap_or_else(|e| e.into_inner())
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
.clone(),
|
.clone(),
|
||||||
steamos: *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()),
|
steamos: *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()),
|
||||||
|
stopped_dm: STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).clone(),
|
||||||
};
|
};
|
||||||
if state.stopped_autologin.is_empty() && !state.steamos {
|
if state.stopped_autologin.is_empty() && !state.steamos && state.stopped_dm.is_none() {
|
||||||
clear_takeover();
|
clear_takeover();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -162,17 +193,22 @@ pub fn restore_takeover_on_startup() {
|
|||||||
clear_takeover();
|
clear_takeover();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if state.stopped_autologin.is_empty() && !state.steamos {
|
if state.stopped_autologin.is_empty() && !state.steamos && state.stopped_dm.is_none() {
|
||||||
clear_takeover();
|
clear_takeover();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
units = ?state.stopped_autologin,
|
units = ?state.stopped_autologin,
|
||||||
steamos = state.steamos,
|
steamos = state.steamos,
|
||||||
|
stopped_dm = ?state.stopped_dm,
|
||||||
"gamescope: found a stranded takeover from a previous host instance — scheduling TV restore"
|
"gamescope: found a stranded takeover from a previous host instance — scheduling TV restore"
|
||||||
);
|
);
|
||||||
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_autologin;
|
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_autologin;
|
||||||
*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = state.steamos;
|
*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = state.steamos;
|
||||||
|
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_dm;
|
||||||
|
// Re-baseline the session-select sentinel: after a crash-restore the launch-time baseline is
|
||||||
|
// gone, and a long-existing sentinel file must not read as a fresh in-stream switch request.
|
||||||
|
record_session_select_baseline();
|
||||||
// A generous grace so a client reconnecting right after the restart cancels it (create_managed_session
|
// A generous grace so a client reconnecting right after the restart cancels it (create_managed_session
|
||||||
// clears PENDING_RESTORE) and keeps the streamed session rather than bouncing to gaming mode.
|
// clears PENDING_RESTORE) and keeps the streamed session rather than bouncing to gaming mode.
|
||||||
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) =
|
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||||
@@ -263,7 +299,10 @@ impl VirtualDisplay for GamescopeDisplay {
|
|||||||
// A3 takeover machinery (recorded in STOPPED_AUTOLOGIN + persisted; restarted on session end via
|
// A3 takeover machinery (recorded in STOPPED_AUTOLOGIN + persisted; restarted on session end via
|
||||||
// schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this.
|
// schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this.
|
||||||
if self.cmd.as_deref().is_some_and(is_steam_launch) {
|
if self.cmd.as_deref().is_some_and(is_steam_launch) {
|
||||||
stop_autologin_sessions();
|
// 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()
|
||||||
|
.context("dedicated Steam launch needs the box's gaming session freed")?;
|
||||||
// B1b: a Steam running in a plain DESKTOP session (GNOME/KDE) holds the instance just
|
// 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.
|
// the same, and the autologin stop above can't see it — free it too, or fail loudly.
|
||||||
free_desktop_steam()?;
|
free_desktop_steam()?;
|
||||||
@@ -326,6 +365,40 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
|||||||
if steamos_session_present() {
|
if steamos_session_present() {
|
||||||
return create_managed_session_steamos(mode);
|
return create_managed_session_steamos(mode);
|
||||||
}
|
}
|
||||||
|
// In-stream "Switch to Desktop" under a DM-stop takeover: the user's session-select inside
|
||||||
|
// the streamed game mode advanced the sentinel, but its config rewrite was a silent no-op
|
||||||
|
// (every write branch needs the DM running, and the takeover stopped it) — so without this,
|
||||||
|
// the capture loss it caused would just relaunch game mode ("thrown back in", field-tested
|
||||||
|
// 2026-07-24). Honor the request instead: restore the DM and replay the switch.
|
||||||
|
let dm_takeover = STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||||
|
if let Some(dm) = dm_takeover {
|
||||||
|
if session_select_requested() {
|
||||||
|
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
|
honor_session_select_switch(dm);
|
||||||
|
return Err(anyhow!(
|
||||||
|
"the user switched the box to the desktop session — display manager restored; \
|
||||||
|
re-detection follows the desktop compositor as it comes up"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Post-honor grace: while the selected desktop boots, a managed relaunch would win the race
|
||||||
|
// (gamescope+Steam start faster than KWin) and a delivering pipeline ends the rebuild's
|
||||||
|
// re-detection — right back in game mode. A live box-owned game-mode unit supersedes the
|
||||||
|
// grace: the user already switched back, so managed may proceed.
|
||||||
|
let honor_pending = SWITCH_HONORED_AT
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.is_some_and(|t| t.elapsed() < SWITCH_HONOR_GRACE);
|
||||||
|
if honor_pending {
|
||||||
|
if running_autologin_gamescope_unit().is_some() {
|
||||||
|
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
|
} else {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"waiting for the desktop session the user selected — refusing to relaunch game \
|
||||||
|
mode (re-detection follows the desktop once it's up)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
// Attach-only rebuild probe: reuse a live same-mode session, but NEVER stop/relaunch box
|
// Attach-only rebuild probe: reuse a live same-mode session, but NEVER stop/relaunch box
|
||||||
// sessions — right after a capture loss the caller's session detection can be stale, and a
|
// sessions — right after a capture loss the caller's session detection can be stale, and a
|
||||||
// destructive rebuild here would fight the session the user just switched to.
|
// destructive rebuild here would fight the session the user just switched to.
|
||||||
@@ -353,7 +426,28 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
|||||||
// Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and
|
// Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and
|
||||||
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
|
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
|
||||||
// stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce.
|
// stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce.
|
||||||
stop_autologin_sessions();
|
// On a mask-fragile-DM box without the privilege to stop the DM, the takeover would destabilize
|
||||||
|
// the seat — degrade to ATTACH instead: mirror the box's own live game-mode session (capture +
|
||||||
|
// inject, no lifecycle ownership), which needs no takeover at all.
|
||||||
|
if let Err(e) = stop_autologin_sessions() {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
"gamescope: managed takeover unavailable — degrading to ATTACH (mirroring the box's \
|
||||||
|
own game-mode session)"
|
||||||
|
);
|
||||||
|
let node_id = ensure_box_gamescope_mode(mode)?;
|
||||||
|
point_injector_at_eis();
|
||||||
|
return Ok(VirtualOutput {
|
||||||
|
node_id,
|
||||||
|
remote_fd: None,
|
||||||
|
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||||
|
keepalive: Box::new(()),
|
||||||
|
ownership: DisplayOwnership::External,
|
||||||
|
reused_gen: None,
|
||||||
|
pool_gen: None,
|
||||||
|
expect_exact_dims: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
// B1b: a desktop-session Steam (outside any gamescope unit) also holds the single instance and
|
// B1b: a desktop-session Steam (outside any gamescope unit) also holds the single instance and
|
||||||
// would make the managed session's own Steam exit at birth. The managed session's Steam itself
|
// would make the managed session's own Steam exit at birth. The managed session's Steam itself
|
||||||
// is exempt (it lives in the SESSION_UNIT cgroup), so the same-mode reuse below is unaffected.
|
// is exempt (it lives in the SESSION_UNIT cgroup), so the same-mode reuse below is unaffected.
|
||||||
@@ -379,7 +473,21 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
|||||||
}
|
}
|
||||||
// (Re)launch at the new mode. `launch_session` stops the old unit by name first, so there is
|
// (Re)launch at the new mode. `launch_session` stops the old unit by name first, so there is
|
||||||
// exactly one gamescope `Video/Source` node for discovery.
|
// exactly one gamescope `Video/Source` node for discovery.
|
||||||
let node_id = launch_session(client, SESSION_UNIT, mode)?;
|
let node_id = match launch_session(client, SESSION_UNIT, mode) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(e) => {
|
||||||
|
// The takeover already happened (autologin units stopped, possibly the DM down) — arm
|
||||||
|
// the restore now, or a failed launch strands the box sessionless until a host
|
||||||
|
// restart. Policy-timed; a quick client retry cancels it and relaunches warm.
|
||||||
|
// MANAGED_SESSION must be released first: the scheduler reads it (orphan detection).
|
||||||
|
drop(guard);
|
||||||
|
schedule_restore_tv_session();
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Baseline the session-select sentinel NOW: only a write from INSIDE this session (the user's
|
||||||
|
// "Switch to Desktop") should read as a switch request, not the one that led here.
|
||||||
|
record_session_select_baseline();
|
||||||
point_injector_at_eis();
|
point_injector_at_eis();
|
||||||
*guard = Some(SessionState {
|
*guard = Some(SessionState {
|
||||||
width: mode.width,
|
width: mode.width,
|
||||||
@@ -807,6 +915,38 @@ fn ensure_box_gamescope_mode(mode: Mode) -> Result<u32> {
|
|||||||
return Ok(node);
|
return Ok(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Attach-only rebuild probe (parity with both managed paths — this gap was the attach-path
|
||||||
|
// stale-detection hazard): right after a capture loss the caller's session detection can be
|
||||||
|
// stale, and a set-environment + unit restart here would fight the session the user just
|
||||||
|
// switched to. Mirror whatever live node exists at its own mode; refuse otherwise.
|
||||||
|
if crate::rebuild_probe_active() {
|
||||||
|
if let Some(node) = find_gamescope_node() {
|
||||||
|
tracing::info!(
|
||||||
|
node,
|
||||||
|
"gamescope: attach-only rebuild probe — mirroring the live node at its own mode"
|
||||||
|
);
|
||||||
|
return Ok(node);
|
||||||
|
}
|
||||||
|
return Err(anyhow!(
|
||||||
|
"no live gamescope node — attach-only rebuild probe refuses to restart the box's \
|
||||||
|
session (re-detection follows the live session)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// A box driving a PHYSICAL display is mirrored at its own mode, never re-moded: the re-mode
|
||||||
|
// restart is the headless-box model (no panel ⇒ the game-mode resolution is ours to set);
|
||||||
|
// on-glass it would flip the user's own screen to the client's resolution — and on a
|
||||||
|
// DM-session-driven box (Nobara) the unit restart bounces the login session with it.
|
||||||
|
if physical_display_connected() {
|
||||||
|
if let Some(node) = find_gamescope_node() {
|
||||||
|
tracing::info!(
|
||||||
|
node,
|
||||||
|
client_w = mode.width,
|
||||||
|
client_h = mode.height,
|
||||||
|
"gamescope: box drives a physical display — attaching at its own mode (no re-mode)"
|
||||||
|
);
|
||||||
|
return Ok(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
let Some(unit) = running_autologin_gamescope_unit() else {
|
let Some(unit) = running_autologin_gamescope_unit() else {
|
||||||
// No box-owned autologin session to reconfigure (a bare/foreign gamescope): attach to
|
// No box-owned autologin session to reconfigure (a bare/foreign gamescope): attach to
|
||||||
// whatever node exists, accepting its resolution.
|
// whatever node exists, accepting its resolution.
|
||||||
@@ -972,17 +1112,193 @@ fn unmask_unit(unit: &str) {
|
|||||||
.status();
|
.status();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The unit name of the display manager driving this box's graphical logins, from the
|
||||||
|
/// `display-manager.service` alias symlink (the Fedora/Arch/openSUSE convention every
|
||||||
|
/// gamescope-session distro follows). `None` when no DM is installed (a box that boots straight
|
||||||
|
/// into a user session — getty autologin / an enabled user unit).
|
||||||
|
fn display_manager_unit() -> Option<String> {
|
||||||
|
display_manager_unit_under(std::path::Path::new("/etc/systemd/system"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`display_manager_unit`] against an arbitrary root (the unit-testable core).
|
||||||
|
fn display_manager_unit_under(base: &std::path::Path) -> Option<String> {
|
||||||
|
let target = std::fs::read_link(base.join("display-manager.service")).ok()?;
|
||||||
|
target.file_name().map(|n| n.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does this display manager's autologin loop SURVIVE the gamescope unit being masked? Only SDDM is
|
||||||
|
/// proven to (Bazzite/SteamOS `Relogin=true` — the mask is what stops its relogin from restarting
|
||||||
|
/// the unit mid-stream, diagnosed live on .181 2026-07-07; a failing autologin leaves sddm itself
|
||||||
|
/// running). Nobara's `plasmalogin` (KDE's SDDM successor) is proven FATAL: against a masked unit
|
||||||
|
/// its session Exec fails instantly, `Relogin=true` retries, and `plasmalogin.service` trips
|
||||||
|
/// systemd's start limit within ~1 s — the DM dies and the box is a permanent black screen that
|
||||||
|
/// only a root `reset-failed` + `restart` recovers (live-proven on the Nobara repro VM
|
||||||
|
/// 2026-07-24). Unknown DMs are treated as fragile: the fragile path degrades gracefully, a wrong
|
||||||
|
/// "safe" kills the seat.
|
||||||
|
fn dm_survives_masked_unit(dm: &str) -> bool {
|
||||||
|
dm == "sddm.service"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
|
||||||
|
/// the SYSTEM bus — succeeds as root or under an operator polkit rule scoped to the DM unit (see
|
||||||
|
/// docs); fails cleanly otherwise ("interactive authentication required"), in which case the
|
||||||
|
/// caller degrades to attach.
|
||||||
|
fn try_stop_display_manager(dm: &str) -> bool {
|
||||||
|
Command::new("systemctl")
|
||||||
|
.args(["stop", dm])
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the
|
||||||
|
/// sentinel + self-pkexecs (authorized `allow_any` by the distro's own polkit action policy, so
|
||||||
|
/// it works from our sessionless context); its ROOT pass rewrites the DM autologin config — but
|
||||||
|
/// only while the DM is RUNNING, which is why the takeover must restart the DM before calling it.
|
||||||
|
const OS_SESSION_SELECT: &str = "/usr/libexec/os-session-select";
|
||||||
|
|
||||||
|
/// The sentinel Steam's `steamos-session-select` writes in its user pass
|
||||||
|
/// (`~/.config/steamos-session-select`) — see [`SESSION_SELECT_BASELINE`].
|
||||||
|
fn session_select_sentinel() -> Option<std::path::PathBuf> {
|
||||||
|
let home = std::env::var("HOME").ok()?;
|
||||||
|
Some(
|
||||||
|
std::path::Path::new(&home)
|
||||||
|
.join(".config")
|
||||||
|
.join("steamos-session-select"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current mtime of the session-select sentinel (`None` when it doesn't exist yet).
|
||||||
|
fn session_select_mtime() -> Option<std::time::SystemTime> {
|
||||||
|
let path = session_select_sentinel()?;
|
||||||
|
std::fs::metadata(path).ok()?.modified().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the sentinel baseline at managed-session launch, so a LATER write (the user's in-stream
|
||||||
|
/// "Switch to Desktop") is distinguishable from the switch that led into this session.
|
||||||
|
fn record_session_select_baseline() {
|
||||||
|
*SESSION_SELECT_BASELINE
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner()) = session_select_mtime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Did a session-select run inside the managed session since its launch (sentinel newer than the
|
||||||
|
/// recorded baseline, or newly created)? Inside a managed game session the only switch Steam
|
||||||
|
/// offers is TO the desktop, so an advanced sentinel reads as that request.
|
||||||
|
fn session_select_requested() -> bool {
|
||||||
|
let baseline = *SESSION_SELECT_BASELINE
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
|
match (baseline, session_select_mtime()) {
|
||||||
|
(Some(base), Some(now)) => now > base,
|
||||||
|
(None, Some(_)) => true, // created during the session
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Honor the user's in-stream "Switch to Desktop" under a DM-stop takeover. The OS flow was a
|
||||||
|
/// silent no-op (the switch script's config rewrite requires a running DM, which the takeover
|
||||||
|
/// stopped), so replay it with the DM up — every verb live-validated on the Nobara repro VM:
|
||||||
|
/// 1. consume the takeover and start the DM (its autologin heads back into game mode briefly —
|
||||||
|
/// the config still names it);
|
||||||
|
/// 2. run the distro's own `os-session-select desktop` as the user (its internal pkexec is
|
||||||
|
/// `allow_any`-authorized), which rewrites the DM autologin config to the desktop session;
|
||||||
|
/// 3. stop the autologin gamescope unit — the login session exits, and `Relogin=true` relogs
|
||||||
|
/// into the now-selected desktop.
|
||||||
|
///
|
||||||
|
/// The caller then refuses managed relaunches for [`SWITCH_HONOR_GRACE`] so the capture-loss
|
||||||
|
/// re-detection follows the desktop compositor once it's up instead of racing it.
|
||||||
|
fn honor_session_select_switch(dm: String) {
|
||||||
|
tracing::info!(
|
||||||
|
%dm,
|
||||||
|
"gamescope: in-stream session-select detected — restoring the display manager and \
|
||||||
|
switching the box to the desktop session"
|
||||||
|
);
|
||||||
|
// Consume the takeover state up front: from here on the box is the DM's again.
|
||||||
|
std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()));
|
||||||
|
clear_takeover();
|
||||||
|
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
|
stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["reset-failed", &dm])
|
||||||
|
.status();
|
||||||
|
let _ = Command::new("systemctl").args(["start", &dm]).status();
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
let active = Command::new("systemctl")
|
||||||
|
.args(["is-active", &dm])
|
||||||
|
.output()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
|
||||||
|
.unwrap_or(false);
|
||||||
|
if active {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
// Rewrite the autologin session via the distro's own switch helper (needs the DM running).
|
||||||
|
// Absent/failing helper degrades to a plain DM restore — the box lands back in game mode on
|
||||||
|
// glass and the stream follows that instead (no black screen either way).
|
||||||
|
if std::path::Path::new(OS_SESSION_SELECT).exists() {
|
||||||
|
match Command::new(OS_SESSION_SELECT).arg("desktop").status() {
|
||||||
|
Ok(s) if s.success() => {
|
||||||
|
// The relogin only fires when the CURRENT (game-mode) login session exits: wait
|
||||||
|
// for its autologin unit to come up, then stop it. Never mask here — the mask is
|
||||||
|
// what start-limit-kills this DM flavor.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(15);
|
||||||
|
loop {
|
||||||
|
if let Some(unit) = running_autologin_gamescope_unit() {
|
||||||
|
systemctl_user(&["stop", &unit]);
|
||||||
|
tracing::info!(
|
||||||
|
%unit,
|
||||||
|
"gamescope: desktop selected — stopped the game-mode session so the \
|
||||||
|
DM relogs into the desktop"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
tracing::warn!(
|
||||||
|
"gamescope: game-mode session never appeared after the DM restart — \
|
||||||
|
the desktop switch may need a manual session exit"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => tracing::warn!(
|
||||||
|
status = ?other,
|
||||||
|
"gamescope: os-session-select failed — leaving the box in its configured session"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"gamescope: no {OS_SESSION_SELECT} on this box — restored the DM into its configured \
|
||||||
|
session instead of switching to the desktop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
record_session_select_baseline();
|
||||||
|
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
|
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
|
||||||
/// single-instance Steam is free for our own host-managed session. Records the units so
|
/// single-instance Steam is free for our own host-managed session. Records the units so
|
||||||
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
|
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
|
||||||
/// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing
|
/// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing
|
||||||
/// is autologged in (e.g. a box that boots headless). Each unit is **masked first** ([`mask_unit`] —
|
/// is autologged in (e.g. a box that boots headless).
|
||||||
/// SDDM's `Relogin=true` would otherwise restart it instantly), then torn down with **SIGKILL**
|
///
|
||||||
/// ([`kill_unit`]) to avoid the F44 GPU-context leak that the autologin's SIGTERM stop triggers.
|
/// The teardown is DM-flavor-aware ([`dm_survives_masked_unit`]):
|
||||||
/// Matches every loaded instance, not just `running` ones — under the SDDM relogin churn the unit
|
/// * **SDDM / no DM**: each unit is **masked first** ([`mask_unit`] — SDDM's `Relogin=true` would
|
||||||
/// flaps through `activating`/`failed` between cycles, and an unmasked flapping unit re-enters the
|
/// otherwise restart it instantly), then torn down with **SIGKILL** ([`kill_unit`]) to avoid the
|
||||||
/// fight the moment the supervisor restarts it.
|
/// F44 GPU-context leak that the autologin's SIGTERM stop triggers. Matches every loaded
|
||||||
fn stop_autologin_sessions() {
|
/// instance, not just `running` ones — under the SDDM relogin churn the unit flaps through
|
||||||
|
/// `activating`/`failed` between cycles, and an unmasked flapping unit re-enters the fight the
|
||||||
|
/// moment the supervisor restarts it.
|
||||||
|
/// * **Mask-fragile DM** (Nobara's `plasmalogin`, unknown DMs): masking start-limit-kills the DM
|
||||||
|
/// itself (permanent black screen), so instead **stop the DM** — no supervisor left to relogin —
|
||||||
|
/// then SIGKILL the units unmasked. Needs privilege (root / an operator polkit rule); without it
|
||||||
|
/// nothing is touched and the error tells the caller to degrade to ATTACH (mirror the box's own
|
||||||
|
/// session) rather than destabilize the seat.
|
||||||
|
fn stop_autologin_sessions() -> Result<()> {
|
||||||
let Ok(out) = Command::new("systemctl")
|
let Ok(out) = Command::new("systemctl")
|
||||||
.args([
|
.args([
|
||||||
"--user",
|
"--user",
|
||||||
@@ -995,26 +1311,65 @@ fn stop_autologin_sessions() {
|
|||||||
])
|
])
|
||||||
.output()
|
.output()
|
||||||
else {
|
else {
|
||||||
return;
|
return Ok(());
|
||||||
};
|
};
|
||||||
let mut stopped = Vec::new();
|
// `(unit, ACTIVE state)` — the `--plain` columns are UNIT LOAD ACTIVE SUB DESCRIPTION.
|
||||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
let listed: Vec<(String, String)> = String::from_utf8_lossy(&out.stdout)
|
||||||
if let Some(unit) = line.split_whitespace().next() {
|
.lines()
|
||||||
if unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service") {
|
.filter_map(|l| {
|
||||||
mask_unit(unit); // block the SDDM relogin loop from restarting it mid-stream
|
let mut cols = l.split_whitespace();
|
||||||
kill_unit(unit); // SIGKILL teardown — avoid the F44 GPU-context leak
|
let unit = cols.next()?;
|
||||||
tracing::info!(
|
let active = cols.nth(1).unwrap_or("");
|
||||||
unit,
|
(unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service"))
|
||||||
"freed Steam: masked + SIGKILL-stopped the autologin gaming session for this stream"
|
.then(|| (unit.to_string(), active.to_string()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if listed.is_empty() {
|
||||||
|
return Ok(()); // nothing autologged in — Steam is already free
|
||||||
|
}
|
||||||
|
let dm = display_manager_unit();
|
||||||
|
let mask_safe = dm.as_deref().is_none_or(dm_survives_masked_unit);
|
||||||
|
if !mask_safe {
|
||||||
|
// Only a LIVE instance holds Steam / justifies touching the DM. A loaded-but-inactive
|
||||||
|
// leftover (the box switched back to the desktop earlier) must not stop the DM — that
|
||||||
|
// would kill the user's live desktop to free nothing.
|
||||||
|
if !listed
|
||||||
|
.iter()
|
||||||
|
.any(|(_, active)| matches!(active.as_str(), "active" | "activating"))
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let dm = dm.expect("!is_none_or ⇒ Some");
|
||||||
|
if !try_stop_display_manager(&dm) {
|
||||||
|
bail!(
|
||||||
|
"the box's gaming session is driven by {dm}, which does not survive a masked \
|
||||||
|
session unit, and stopping it needs privilege — install the punktfunk \
|
||||||
|
display-manager polkit rule (see docs) to enable the managed takeover"
|
||||||
);
|
);
|
||||||
stopped.push(unit.to_string());
|
|
||||||
}
|
}
|
||||||
|
tracing::info!(
|
||||||
|
%dm,
|
||||||
|
"freed Steam: stopped the display manager for this stream (mask-fragile DM flavor)"
|
||||||
|
);
|
||||||
|
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm);
|
||||||
}
|
}
|
||||||
|
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
|
||||||
|
let mut stopped = Vec::new();
|
||||||
|
for unit in units {
|
||||||
|
if mask_safe {
|
||||||
|
mask_unit(&unit); // block the SDDM relogin loop from restarting it mid-stream
|
||||||
|
}
|
||||||
|
kill_unit(&unit); // SIGKILL teardown — avoid the F44 GPU-context leak
|
||||||
|
tracing::info!(
|
||||||
|
%unit,
|
||||||
|
masked = mask_safe,
|
||||||
|
"freed Steam: stopped the autologin gaming session for this stream"
|
||||||
|
);
|
||||||
|
stopped.push(unit);
|
||||||
}
|
}
|
||||||
if !stopped.is_empty() {
|
|
||||||
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped;
|
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped;
|
||||||
persist_takeover(); // A3: survive a host crash mid-stream
|
persist_takeover(); // A3: survive a host crash mid-stream
|
||||||
}
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long a desktop Steam gets to honor `steam -shutdown` before the spawn fails. Steam tears
|
/// How long a desktop Steam gets to honor `steam -shutdown` before the spawn fails. Steam tears
|
||||||
@@ -1159,7 +1514,16 @@ pub fn schedule_restore_tv_session() {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
.is_empty()
|
.is_empty()
|
||||||
&& !*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner());
|
&& !*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner())
|
||||||
|
&& STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).is_none()
|
||||||
|
// A managed session that took nothing over (started beside a live desktop — e.g. a client
|
||||||
|
// gamescope pin on a KDE box) still owns the transient SESSION_UNIT: without this arm it
|
||||||
|
// was ORPHANED forever after disconnect ("closing the app does not end the session",
|
||||||
|
// field report 2026-07-24) — the restore stops it even with no autologin to bring back.
|
||||||
|
&& MANAGED_SESSION
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.is_none();
|
||||||
if nothing_to_restore {
|
if nothing_to_restore {
|
||||||
return; // nothing was taken over → nothing to restore (also the non-managed path)
|
return; // nothing was taken over → nothing to restore (also the non-managed path)
|
||||||
}
|
}
|
||||||
@@ -1254,8 +1618,24 @@ fn do_restore_tv_session() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let units = std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()));
|
let units = std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()));
|
||||||
if units.is_empty() {
|
let dm = std::mem::take(&mut *STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()));
|
||||||
return; // nothing was stolen → nothing to restore (also the non-Bazzite path)
|
if units.is_empty() && dm.is_none() {
|
||||||
|
// Nothing was stolen — but a managed session that started BESIDE a live desktop (client
|
||||||
|
// gamescope pin on a KDE box) still owns the transient unit; stop it so it doesn't run
|
||||||
|
// orphaned forever after the disconnect. No-op when the unit isn't running.
|
||||||
|
if MANAGED_SESSION
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.take()
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
stop_session(SESSION_UNIT);
|
||||||
|
tracing::info!(
|
||||||
|
"gamescope: stopped the idle managed session (nothing was taken over — no box \
|
||||||
|
session to restore)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
clear_takeover(); // A3: takeover consumed — drop the persisted crash-restore marker
|
clear_takeover(); // A3: takeover consumed — drop the persisted crash-restore marker
|
||||||
stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin
|
stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin
|
||||||
@@ -1266,18 +1646,54 @@ fn do_restore_tv_session() {
|
|||||||
}
|
}
|
||||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
|
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
|
||||||
// user switched to a desktop session (KDE/GNOME/wlroots) in the meantime, don't yank them back
|
// user switched to a desktop session (KDE/GNOME/wlroots/Hyprland) in the meantime, don't yank
|
||||||
// to gaming — leave the desktop alone. (We still stopped our idle managed session above.)
|
// them back to gaming — leave the desktop alone. (We still stopped our idle managed session
|
||||||
|
// above.)
|
||||||
use super::ActiveKind;
|
use super::ActiveKind;
|
||||||
if matches!(
|
if matches!(
|
||||||
super::detect_active_session().kind,
|
super::detect_active_session().kind,
|
||||||
ActiveKind::DesktopKde | ActiveKind::DesktopGnome | ActiveKind::DesktopWlroots
|
ActiveKind::DesktopKde
|
||||||
|
| ActiveKind::DesktopGnome
|
||||||
|
| ActiveKind::DesktopWlroots
|
||||||
|
| ActiveKind::DesktopHyprland
|
||||||
) {
|
) {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"gamescope: a desktop session is active — not restoring the TV gaming session"
|
"gamescope: a desktop session is active — not restoring the TV gaming session"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Mask-fragile-DM takeover: the gamescope unit CANNOT be `--user start`ed back — without a DM
|
||||||
|
// login session there is no seat, so gamescope never gets DRM master (unit goes `failed`,
|
||||||
|
// screen stays black — live-proven on the Nobara repro VM). Restore the DM instead
|
||||||
|
// (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin
|
||||||
|
// session Exec starts the gamescope unit itself.
|
||||||
|
if let Some(dm) = dm {
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["reset-failed", &dm])
|
||||||
|
.status();
|
||||||
|
let restart = Command::new("systemctl")
|
||||||
|
.args(["restart", &dm])
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if restart {
|
||||||
|
tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)");
|
||||||
|
} else if crate::try_recover_session() {
|
||||||
|
tracing::warn!(
|
||||||
|
%dm,
|
||||||
|
"display-manager restart lost its privilege — fired PUNKTFUNK_RECOVER_SESSION_CMD \
|
||||||
|
to bring the session back"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::error!(
|
||||||
|
%dm,
|
||||||
|
"could not restart the display manager and no PUNKTFUNK_RECOVER_SESSION_CMD is \
|
||||||
|
configured — the box has no graphical session until someone runs \
|
||||||
|
`systemctl reset-failed {dm} && systemctl restart {dm}` as root"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
for unit in units {
|
for unit in units {
|
||||||
let _ = Command::new("systemctl")
|
let _ = Command::new("systemctl")
|
||||||
.args(["--user", "start", &unit])
|
.args(["--user", "start", &unit])
|
||||||
@@ -1641,10 +2057,35 @@ impl Drop for GamescopeProc {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
cgroup_is_punktfunk_owned, connected_connector_under, is_steam_launch,
|
cgroup_is_punktfunk_owned, connected_connector_under, display_manager_unit_under,
|
||||||
shape_dedicated_command,
|
dm_survives_masked_unit, is_steam_launch, shape_dedicated_command,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_manager_flavor_detection() {
|
||||||
|
let base = std::env::temp_dir().join(format!("pf-dm-scan-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
// No alias symlink (no DM installed — getty autologin boxes) → None.
|
||||||
|
assert_eq!(display_manager_unit_under(&base), None);
|
||||||
|
// The Fedora-style alias symlink resolves to its target's basename (read_link, not
|
||||||
|
// canonicalize — the target needn't exist on the build box).
|
||||||
|
std::os::unix::fs::symlink(
|
||||||
|
"/usr/lib/systemd/system/plasmalogin.service",
|
||||||
|
base.join("display-manager.service"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
display_manager_unit_under(&base).as_deref(),
|
||||||
|
Some("plasmalogin.service")
|
||||||
|
);
|
||||||
|
// Only SDDM is proven to survive a masked session unit; plasmalogin start-limit-kills
|
||||||
|
// itself (live-proven), and unknown DMs default to fragile.
|
||||||
|
assert!(dm_survives_masked_unit("sddm.service"));
|
||||||
|
assert!(!dm_survives_masked_unit("plasmalogin.service"));
|
||||||
|
assert!(!dm_survives_masked_unit("gdm.service"));
|
||||||
|
std::fs::remove_dir_all(&base).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn connector_status_scan() {
|
fn connector_status_scan() {
|
||||||
let base = std::env::temp_dir().join(format!("pf-drm-scan-{}", std::process::id()));
|
let base = std::env::temp_dir().join(format!("pf-drm-scan-{}", std::process::id()));
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ pub struct KwinDisplay {
|
|||||||
/// [`apply_position`](VirtualDisplay::apply_position) addresses OUR output even while a
|
/// [`apply_position`](VirtualDisplay::apply_position) addresses OUR output even while a
|
||||||
/// superseded same-name sibling is still alive.
|
/// superseded same-name sibling is still alive.
|
||||||
last_name: Option<String>,
|
last_name: Option<String>,
|
||||||
|
/// The RESOLVED `kde_output_device_v2` UUID of the last `create`'s output, when the in-process
|
||||||
|
/// output-management path handled the topology. A stable per-output id (unlike the shared name),
|
||||||
|
/// so [`apply_position`](VirtualDisplay::apply_position) and restore address exactly OUR output
|
||||||
|
/// across a supersede — preferred over `last_name`, which is only the kscreen-doctor fallback.
|
||||||
|
our_uuid: Option<String>,
|
||||||
/// The topology-restore action the last `create` prepared (re-enable the outputs an `exclusive`
|
/// The topology-restore action the last `create` prepared (re-enable the outputs an `exclusive`
|
||||||
/// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
|
/// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
|
||||||
/// physical is re-enabled only when the display GROUP's last member drops (§6.1), not this session's.
|
/// physical is re-enabled only when the display GROUP's last member drops (§6.1), not this session's.
|
||||||
@@ -114,6 +119,48 @@ impl KwinDisplay {
|
|||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
Ok(KwinDisplay::default())
|
Ok(KwinDisplay::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply the effective display topology for the just-created output `our_prefix` (current size
|
||||||
|
/// `dims`), preferring the in-process `kde_output_management_v2` path and falling back to
|
||||||
|
/// `kscreen-doctor` if the compositor doesn't answer in budget or the management global is
|
||||||
|
/// absent. Records the output's UUID (in-process) or kscreen address (fallback) for
|
||||||
|
/// [`apply_position`](VirtualDisplay::apply_position), and returns the disabled outputs (each
|
||||||
|
/// `(name, "WxH@Hz")`) for the group teardown restore. `Extend`/`Auto` disable nothing.
|
||||||
|
fn apply_topology(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
our_prefix: &str,
|
||||||
|
dims: (u32, u32),
|
||||||
|
) -> Vec<(String, String)> {
|
||||||
|
use crate::kwin_output_mgmt::TopologyKind;
|
||||||
|
use crate::policy::Topology;
|
||||||
|
let topology = crate::effective_topology();
|
||||||
|
let kind = match topology {
|
||||||
|
Topology::Exclusive => TopologyKind::Exclusive,
|
||||||
|
Topology::Primary => TopologyKind::Primary,
|
||||||
|
Topology::Extend | Topology::Auto => return Vec::new(),
|
||||||
|
};
|
||||||
|
// In-process over Wayland — immune to whatever wedges the standalone kscreen-doctor.
|
||||||
|
let outcome = crate::kwin_output_mgmt::apply_topology(our_prefix, dims.0, dims.1, kind);
|
||||||
|
if outcome.handled {
|
||||||
|
self.our_uuid = outcome.our_uuid;
|
||||||
|
return outcome.disabled;
|
||||||
|
}
|
||||||
|
// Fallback: kscreen-doctor — resolve our address the old way, then shell out the topology.
|
||||||
|
tracing::info!(
|
||||||
|
"KWin topology: kde_output_management unavailable — kscreen-doctor fallback"
|
||||||
|
);
|
||||||
|
let addr = resolve_kscreen_addr(name, dims.0, dims.1);
|
||||||
|
self.last_name = Some(addr.clone());
|
||||||
|
match topology {
|
||||||
|
Topology::Exclusive => apply_virtual_primary(&addr),
|
||||||
|
Topology::Primary => {
|
||||||
|
apply_virtual_primary_only(&addr);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
Topology::Extend | Topology::Auto => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VirtualDisplay for KwinDisplay {
|
impl VirtualDisplay for KwinDisplay {
|
||||||
@@ -142,8 +189,15 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn apply_position(&mut self, x: i32, y: i32) {
|
fn apply_position(&mut self, x: i32, y: i32) {
|
||||||
// `last_name` holds the RESOLVED kscreen address (numeric output id, or the
|
// Prefer the in-process path: address OUR output by its stable UUID (supersede-robust) over
|
||||||
// `Virtual-<name>` fallback) — never re-derive from the name: during a supersede two
|
// kde_output_management_v2 — immune to a wedged kscreen-doctor backend (see kwin_output_mgmt).
|
||||||
|
if let Some(uuid) = self.our_uuid.clone() {
|
||||||
|
if crate::kwin_output_mgmt::set_position(&uuid, x, y) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: kscreen-doctor. `last_name` holds the RESOLVED kscreen address (numeric output id,
|
||||||
|
// or the `Virtual-<name>` fallback) — never re-derive from the name: during a supersede two
|
||||||
// outputs share it and the command would hit the old one (see `create`).
|
// outputs share it and the command would hit the old one (see `create`).
|
||||||
let Some(output) = self.last_name.clone() else {
|
let Some(output) = self.last_name.clone() else {
|
||||||
return;
|
return;
|
||||||
@@ -210,12 +264,12 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
// on virtual outputs since KWin 6.6) then changes the SIZE, and the first buffers recorded
|
// on virtual outputs since KWin 6.6) then changes the SIZE, and the first buffers recorded
|
||||||
// after the consumer connects trigger KWin's resize → a renegotiation to that mode. The
|
// after the consumer connects trigger KWin's resize → a renegotiation to that mode. The
|
||||||
// capturer holds frames until that lands (`expect_exact_dims`), so the pipeline never
|
// capturer holds frames until that lands (`expect_exact_dims`), so the pipeline never
|
||||||
// builds against the birth mode. First cut shells out to kscreen-doctor; the in-process
|
// builds against the birth mode. The install/select runs in-process over
|
||||||
// kde_output_management_v2 client is a follow-up. `set_custom_refresh` reads back what
|
// kde_output_management_v2 (`kwin_output_mgmt::set_custom_mode`), with kscreen-doctor
|
||||||
// KWin *actually* gave — both the rate (so the encoder paces to the real source) and the
|
// (`set_custom_refresh`) as the fallback; either reads back what KWin *actually* gave — both
|
||||||
// size, which KWin's CVT generator may have aligned down (see `CVT_H_GRANULARITY`). At
|
// the rate (so the encoder paces to the real source) and the size, which KWin's CVT
|
||||||
// ≤60 Hz there's nothing to install — the output is born at the real size and 60 Hz is the
|
// generator may have aligned down (see `CVT_H_GRANULARITY`). At ≤60 Hz there's nothing to
|
||||||
// offer anyway.
|
// install — the output is born at the real size and 60 Hz is the offer anyway.
|
||||||
let want_high = mode.refresh_hz > 60;
|
let want_high = mode.refresh_hz > 60;
|
||||||
let birth_h = if want_high { height + 16 } else { height };
|
let birth_h = if want_high { height + 16 } else { height };
|
||||||
let (mut node_id, mut stop) = spawn_vout(width, birth_h)?;
|
let (mut node_id, mut stop) = spawn_vout(width, birth_h)?;
|
||||||
@@ -227,17 +281,10 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
embedded_pointer = !self.hw_cursor,
|
embedded_pointer = !self.hw_cursor,
|
||||||
"KWin virtual output ready"
|
"KWin virtual output ready"
|
||||||
);
|
);
|
||||||
// ⚠️ ADDRESS BY NUMERIC KSCREEN ID, NEVER BY NAME: a supersede (mode switch) creates the
|
// Topology + positioning address OUR output by its kde_output_management UUID (resolved
|
||||||
// replacement output — SAME per-slot name, deliberately, for KWin's per-name config
|
// in-process in `apply_topology`, supersede-robust) — no early kscreen-doctor resolve, so
|
||||||
// persistence — while the superseded sibling is still alive (create-before-drop). Every
|
// the path never shells out. `Virtual-<name>` is the name KWin exposes our output as.
|
||||||
// name-addressed kscreen command then hits the FIRST match = the OLD output: on-glass this
|
let our_prefix = format!("Virtual-{name}");
|
||||||
// resized the LIVE session's display out from under it (wrong-res/black), read back the
|
|
||||||
// OLD output as "custom mode applied", made the OLD output primary, and positioned it —
|
|
||||||
// while the new output never left its birth mode and the capturer's dims gate starved.
|
|
||||||
// Resolve OUR output's kscreen id once (match: managed-prefix name AND current mode ==
|
|
||||||
// the just-created birth size; newest id wins), and use it for every kscreen operation.
|
|
||||||
let mut addr = resolve_kscreen_addr(&name, width, birth_h);
|
|
||||||
self.last_name = Some(addr.clone()); // for apply_position (registry-driven §6.2 layout)
|
|
||||||
let mut expect_exact_dims = false;
|
let mut expect_exact_dims = false;
|
||||||
// The size the output actually ENDS UP at — the request, unless KWin's CVT generator had to
|
// The size the output actually ENDS UP at — the request, unless KWin's CVT generator had to
|
||||||
// shrink the width to the cell grain (see `CVT_H_GRANULARITY`). Reported as the output's
|
// shrink the width to the cell grain (see `CVT_H_GRANULARITY`). Reported as the output's
|
||||||
@@ -245,7 +292,27 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
// encoder opens against, so a CVT-aligned mode flows end-to-end instead of starving.
|
// encoder opens against, so a CVT-aligned mode flows end-to-end instead of starving.
|
||||||
let mut final_dims = (width, height);
|
let mut final_dims = (width, height);
|
||||||
let achieved_hz = if want_high {
|
let achieved_hz = if want_high {
|
||||||
let active = set_custom_refresh(width, height, mode.refresh_hz, &addr);
|
// >60 Hz needs the real high-refresh custom mode installed + selected (sacrificial-birth,
|
||||||
|
// see above). In-process over kde_output_management_v2 first (no kscreen-doctor); fall
|
||||||
|
// back to the kscreen-doctor shell-out on pre-6.6 KWin (no `set_custom_modes`) or if the
|
||||||
|
// compositor doesn't answer in budget.
|
||||||
|
let active = crate::kwin_output_mgmt::set_custom_mode(
|
||||||
|
&our_prefix,
|
||||||
|
width,
|
||||||
|
birth_h,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
mode.refresh_hz,
|
||||||
|
)
|
||||||
|
.or_else(|| {
|
||||||
|
// ⚠️ ADDRESS BY NUMERIC KSCREEN ID, NEVER BY NAME: a supersede reuses the per-slot
|
||||||
|
// name while the superseded sibling is still alive, so a name-addressed kscreen
|
||||||
|
// command hits the OLD output. Resolve our kscreen id for the shell-out install +
|
||||||
|
// keep it as apply_position's kscreen fallback.
|
||||||
|
let addr = resolve_kscreen_addr(&name, width, birth_h);
|
||||||
|
self.last_name = Some(addr.clone());
|
||||||
|
set_custom_refresh(width, height, mode.refresh_hz, &addr)
|
||||||
|
});
|
||||||
// Accept only an active mode that IS our custom one: the exact requested height, and a
|
// Accept only an active mode that IS our custom one: the exact requested height, and a
|
||||||
// width at or just below the request (a CVT alignment). That also proves the output
|
// width at or just below the request (a CVT alignment). That also proves the output
|
||||||
// left the sacrificial birth size, so the recording stream will renegotiate to it.
|
// left the sacrificial birth size, so the recording stream will renegotiate to it.
|
||||||
@@ -275,8 +342,6 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
let (nid, st) = spawn_vout(width, height)?;
|
let (nid, st) = spawn_vout(width, height)?;
|
||||||
node_id = nid;
|
node_id = nid;
|
||||||
stop = st;
|
stop = st;
|
||||||
addr = resolve_kscreen_addr(&name, width, height);
|
|
||||||
self.last_name = Some(addr.clone());
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
node_id,
|
node_id,
|
||||||
width,
|
width,
|
||||||
@@ -293,16 +358,16 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
// `Primary` makes it the primary output but keeps the bootstrap/physical outputs enabled;
|
// `Primary` makes it the primary output but keeps the bootstrap/physical outputs enabled;
|
||||||
// `Exclusive` makes it the SOLE desktop (others disabled, restored on teardown) — so
|
// `Exclusive` makes it the SOLE desktop (others disabled, restored on teardown) — so
|
||||||
// plasmashell + windows land on the streamed surface, not the headless `kwin --virtual`
|
// plasmashell + windows land on the streamed surface, not the headless `kwin --virtual`
|
||||||
// bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean).
|
// bootstrap output. Applied over kde_output_management_v2 in-process (immune to a wedged
|
||||||
use crate::policy::Topology;
|
// kscreen-doctor backend; see `apply_topology`), with a kscreen-doctor fallback. `disabled`
|
||||||
let disabled = match crate::effective_topology() {
|
// is the physical/bootstrap outputs, each `(name, "WxH@Hz")`, to restore on teardown.
|
||||||
Topology::Exclusive => apply_virtual_primary(&addr),
|
let disabled = self.apply_topology(&name, &our_prefix, final_dims);
|
||||||
Topology::Primary => {
|
// A plain managed name is enough for apply_position's kscreen-doctor fallback when the
|
||||||
apply_virtual_primary_only(&addr);
|
// in-process UUID path isn't set (single-output sessions are unambiguous; a supersede uses
|
||||||
Vec::new() // nothing disabled → nothing to restore
|
// the UUID path instead). `want_high` already set `last_name` to the resolved kscreen id.
|
||||||
|
if self.last_name.is_none() {
|
||||||
|
self.last_name = Some(our_prefix);
|
||||||
}
|
}
|
||||||
Topology::Extend | Topology::Auto => Vec::new(),
|
|
||||||
};
|
|
||||||
// Per-group restore (§6.1): DON'T bind the re-enable to this session's keepalive (a per-session
|
// Per-group restore (§6.1): DON'T bind the re-enable to this session's keepalive (a per-session
|
||||||
// `StopGuard` restore would re-enable the physical the moment the FIRST of several exclusive
|
// `StopGuard` restore would re-enable the physical the moment the FIRST of several exclusive
|
||||||
// sessions drops — under a still-live sibling). Instead stash it as a closure the registry lifts
|
// sessions drops — under a still-live sibling). Instead stash it as a closure the registry lifts
|
||||||
@@ -310,7 +375,12 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
// that display's output is reclaimed, so KWin never sees zero outputs). Empty ⇒ nothing to restore.
|
// that display's output is reclaimed, so KWin never sees zero outputs). Empty ⇒ nothing to restore.
|
||||||
self.pending_restore = (!disabled.is_empty()).then(|| {
|
self.pending_restore = (!disabled.is_empty()).then(|| {
|
||||||
let disabled = disabled.clone();
|
let disabled = disabled.clone();
|
||||||
Box::new(move || reenable_outputs(&disabled)) as Box<dyn FnOnce() + Send>
|
// In-process first; fall back to kscreen-doctor if the compositor doesn't answer in budget.
|
||||||
|
Box::new(move || {
|
||||||
|
if !crate::kwin_output_mgmt::reenable_outputs(&disabled) {
|
||||||
|
reenable_outputs_kscreen(&disabled);
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnOnce() + Send>
|
||||||
});
|
});
|
||||||
// Layout position (§6.2) is applied by the registry via `apply_position` right after create
|
// Layout position (§6.2) is applied by the registry via `apply_position` right after create
|
||||||
// (it owns the display group, so it computes auto-row / manual placement over the whole group).
|
// (it owns the display group, so it computes auto-row / manual placement over the whole group).
|
||||||
@@ -324,10 +394,12 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical), so KWin re-homes onto
|
/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical) via `kscreen-doctor`
|
||||||
/// them. Called by the registry when the display group's last member is torn down (design §6.1), BEFORE
|
/// — the fallback for the in-process [`crate::kwin_output_mgmt::reenable_outputs`], run by the restore
|
||||||
/// that member's output is reclaimed — so KWin is never momentarily left with zero enabled outputs.
|
/// closure only when the in-process path reports the compositor didn't answer. Called by the registry
|
||||||
fn reenable_outputs(outputs: &[(String, String)]) {
|
/// when the display group's last member is torn down (design §6.1), BEFORE that member's output is
|
||||||
|
/// reclaimed — so KWin is never momentarily left with zero enabled outputs.
|
||||||
|
fn reenable_outputs_kscreen(outputs: &[(String, String)]) {
|
||||||
if outputs.is_empty() {
|
if outputs.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -823,8 +895,9 @@ fn apply_virtual_primary_only(ours: &str) {
|
|||||||
|
|
||||||
/// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which
|
/// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which
|
||||||
/// drops the Wayland connection and makes KWin reclaim the output. The topology **restore** is no
|
/// drops the Wayland connection and makes KWin reclaim the output. The topology **restore** is no
|
||||||
/// longer bound here — it moved to the registry's display group (§6.1, [`reenable_outputs`]), which
|
/// longer bound here — it moved to the registry's display group (§6.1, restored in-process via
|
||||||
/// runs it once when the group's last member drops, BEFORE this keepalive is dropped.
|
/// [`crate::kwin_output_mgmt::reenable_outputs`], `kscreen-doctor` fallback), which runs it once when
|
||||||
|
/// the group's last member drops, BEFORE this keepalive is dropped.
|
||||||
struct StopGuard {
|
struct StopGuard {
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,820 @@
|
|||||||
|
//! In-process KDE output management (`kde_output_management_v2` + `kde_output_device_v2`).
|
||||||
|
//!
|
||||||
|
//! Topology — make the streamed output primary, disable the physical/bootstrap outputs, capture
|
||||||
|
//! their modes for restore, re-enable them on teardown, position the output — used to shell out to
|
||||||
|
//! `kscreen-doctor` (see [`super::kwin`]). But `kscreen-doctor` drives a *separate* stack:
|
||||||
|
//! libkscreen picks a backend and, depending on the setup, waits on the kscreen KDED module over
|
||||||
|
//! D-Bus. On a machine where THAT layer is wedged it blocks in its own connect and never returns —
|
||||||
|
//! a field report (Nobara, KWin 6.6.4) showed every topology `kscreen-doctor` timing out at its 5 s
|
||||||
|
//! budget, so the streamed output never became the desktop (`also_disabled=[]`) and bring-up took
|
||||||
|
//! ~26 s.
|
||||||
|
//!
|
||||||
|
//! The compositor's OWN Wayland is provably responsive on that same session — the host just created
|
||||||
|
//! a virtual output over it via `zkde_screencast` — so we drive `kde_output_management_v2` directly
|
||||||
|
//! over Wayland here, sidestepping whatever wedges the standalone tool. Every wait is time-bounded,
|
||||||
|
//! so a genuinely wedged compositor degrades to `handled = false` and the caller falls back to the
|
||||||
|
//! `kscreen-doctor` path rather than hanging.
|
||||||
|
//!
|
||||||
|
//! KWin advertises one `kde_output_device_v2` global per output (the classic model; verified live:
|
||||||
|
//! `kde_output_management_v2` v19, `kde_output_device_v2` v20 on KWin 6.6.4). We bind them all, read
|
||||||
|
//! each output's name / enabled / priority / current-mode size, then build a
|
||||||
|
//! `kde_output_configuration_v2` and `apply()` it, waiting for `applied` / `failed`.
|
||||||
|
|
||||||
|
#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::os::fd::{AsFd, AsRawFd};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use wayland_client::backend::ObjectId;
|
||||||
|
use wayland_client::protocol::wl_callback::{self, WlCallback};
|
||||||
|
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||||||
|
use wayland_client::{event_created_child, Connection, Dispatch, Proxy, QueueHandle};
|
||||||
|
|
||||||
|
// Generate the client bindings for the two vendored KDE protocols inline (no build.rs). The
|
||||||
|
// management protocol references the device interfaces, so they can't share one `__interfaces`
|
||||||
|
// module (each `generate_interfaces!` emits its own helper items, which collide). Instead — the
|
||||||
|
// interdependent-protocol pattern from the `wayland-protocols` crate — `device` is a self-contained
|
||||||
|
// module and `management` pulls in `device`'s interface statics + generated proxy types before its
|
||||||
|
// own codegen, so its cross-protocol object args (`kde_output_device_v2`, `…_mode_v2`) resolve.
|
||||||
|
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||||
|
pub mod device {
|
||||||
|
use wayland_client;
|
||||||
|
use wayland_client::protocol::*;
|
||||||
|
|
||||||
|
pub mod __interfaces {
|
||||||
|
use wayland_client::protocol::__interfaces::*;
|
||||||
|
wayland_scanner::generate_interfaces!("protocols/kde-output-device-v2.xml");
|
||||||
|
}
|
||||||
|
use self::__interfaces::*;
|
||||||
|
|
||||||
|
wayland_scanner::generate_client_code!("protocols/kde-output-device-v2.xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||||
|
pub mod management {
|
||||||
|
use wayland_client;
|
||||||
|
use wayland_client::protocol::*;
|
||||||
|
|
||||||
|
pub mod __interfaces {
|
||||||
|
use super::super::device::__interfaces::*;
|
||||||
|
use wayland_client::protocol::__interfaces::*;
|
||||||
|
wayland_scanner::generate_interfaces!("protocols/kde-output-management-v2.xml");
|
||||||
|
}
|
||||||
|
use self::__interfaces::*;
|
||||||
|
// The device protocol's generated modules/types, so the foreign object args resolve.
|
||||||
|
use super::device::*;
|
||||||
|
|
||||||
|
wayland_scanner::generate_client_code!("protocols/kde-output-management-v2.xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
use device::kde_output_device_mode_v2::{Event as ModeEvent, KdeOutputDeviceModeV2 as DeviceMode};
|
||||||
|
use device::kde_output_device_v2::{Event as DeviceEvent, KdeOutputDeviceV2 as OutputDevice};
|
||||||
|
use management::kde_mode_list_v2::KdeModeListV2 as ModeList;
|
||||||
|
use management::kde_output_configuration_v2::{
|
||||||
|
Event as ConfigEvent, KdeOutputConfigurationV2 as OutputConfig,
|
||||||
|
};
|
||||||
|
use management::kde_output_management_v2::KdeOutputManagementV2 as OutputManagement;
|
||||||
|
|
||||||
|
/// Highest interface versions we drive; we bind `min(advertised, MAX)`. Every request we issue is
|
||||||
|
/// `since ≤ 2` (`create_configuration`/`enable`/`mode`/`position`/`apply` are v1, `set_primary_output`
|
||||||
|
/// is v2) and every event we read is `since ≤ 18` (`priority`), so binding high and calling low is
|
||||||
|
/// always in range on any KWin that advertises the globals.
|
||||||
|
const MGMT_MAX: u32 = 22;
|
||||||
|
const DEVICE_MAX: u32 = 24;
|
||||||
|
|
||||||
|
/// The opcode of `kde_output_device_v2.mode` (0-based event index) — the event that creates a child
|
||||||
|
/// `kde_output_device_mode_v2`. Kept in sync with the vendored `kde-output-device-v2.xml`.
|
||||||
|
const DEVICE_MODE_EVENT_OPCODE: u16 = 2;
|
||||||
|
|
||||||
|
/// Overall budget for one enumerate-then-apply operation. Generous next to a healthy roundtrip (a
|
||||||
|
/// few ms); it exists only so a wedged compositor can't pin the session's stream thread.
|
||||||
|
const OP_BUDGET: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// Poll slice while waiting on the Wayland fd (matches the keepalive loop's cadence in `kwin.rs`).
|
||||||
|
const POLL_MS: i32 = 100;
|
||||||
|
|
||||||
|
/// KWin's CVT generator aligns a custom mode's width DOWN to a multiple of this (libxcvt's cell
|
||||||
|
/// grain), so the mode it builds for a `set_custom_modes` request may be a few px narrower than
|
||||||
|
/// asked — matches `kwin::CVT_H_GRANULARITY`. Used when matching the generated mode back.
|
||||||
|
const CVT_H_GRANULARITY: u32 = 8;
|
||||||
|
|
||||||
|
/// Which topology to apply once our output is resolved.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum TopologyKind {
|
||||||
|
/// Make ours the sole desktop: primary + disable every other enabled output.
|
||||||
|
Exclusive,
|
||||||
|
/// Make ours primary but leave the other outputs enabled.
|
||||||
|
Primary,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of [`apply_topology`].
|
||||||
|
pub(crate) struct TopologyOutcome {
|
||||||
|
/// UUID of our resolved virtual output — a stable per-output id that survives a mode-switch
|
||||||
|
/// supersede (unlike the shared name) — for later [`set_position`] / restore addressing.
|
||||||
|
pub our_uuid: Option<String>,
|
||||||
|
/// The outputs we disabled, each `(name, "WxH@Hz")`, so teardown can restore them at their exact
|
||||||
|
/// mode. Empty for `Primary`, or when nothing else was enabled.
|
||||||
|
pub disabled: Vec<(String, String)>,
|
||||||
|
/// `true` if the in-process path bound management, resolved our output, and applied (or tried to)
|
||||||
|
/// a configuration. `false` ⇒ the compositor didn't answer in budget or our output never
|
||||||
|
/// appeared, so the caller should fall back to `kscreen-doctor`.
|
||||||
|
pub handled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One output as read from `kde_output_device_v2`.
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct DeviceState {
|
||||||
|
/// The global `name` number (higher = more recently advertised) — used to pick the newest of two
|
||||||
|
/// same-named outputs during a supersede.
|
||||||
|
global: u32,
|
||||||
|
name: Option<String>,
|
||||||
|
uuid: Option<String>,
|
||||||
|
enabled: bool,
|
||||||
|
/// KWin's output priority; 1 is the primary. `None` until the `priority` event (device ≥ v18).
|
||||||
|
priority: Option<u32>,
|
||||||
|
/// The `current_mode` object id; its size is looked up in [`State::mode_dims`].
|
||||||
|
current_mode: Option<ObjectId>,
|
||||||
|
/// Every mode this output advertised, in announce order — `(mode object id, proxy)` — so restore
|
||||||
|
/// can pick the one matching a captured `WxH@Hz`.
|
||||||
|
modes: Vec<(ObjectId, DeviceMode)>,
|
||||||
|
/// Set once this output's `done` burst has been seen (its state is coherent to read).
|
||||||
|
seen_done: bool,
|
||||||
|
proxy: Option<OutputDevice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the enumerate/apply queue accumulates on one connection.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct State {
|
||||||
|
management: Option<OutputManagement>,
|
||||||
|
mgmt_name_version: Option<(u32, u32)>,
|
||||||
|
devices: HashMap<ObjectId, DeviceState>,
|
||||||
|
/// mode object id → `(width, height, refresh_mHz)`.
|
||||||
|
mode_dims: HashMap<ObjectId, (u32, u32, u32)>,
|
||||||
|
/// Highest `wl_callback` serial whose `done` has arrived — the barrier the pump waits on.
|
||||||
|
sync_done: u32,
|
||||||
|
/// Configuration apply verdict: `Some(true)` = applied, `Some(false)` = failed.
|
||||||
|
applied: Option<bool>,
|
||||||
|
failure_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<WlRegistry, ()> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
registry: &WlRegistry,
|
||||||
|
event: wl_registry::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
qh: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
match event {
|
||||||
|
wl_registry::Event::Global {
|
||||||
|
name,
|
||||||
|
interface,
|
||||||
|
version,
|
||||||
|
} => {
|
||||||
|
if interface == OutputManagement::interface().name {
|
||||||
|
let v = version.min(MGMT_MAX);
|
||||||
|
state.management =
|
||||||
|
Some(registry.bind::<OutputManagement, _, _>(name, v, qh, ()));
|
||||||
|
state.mgmt_name_version = Some((name, v));
|
||||||
|
} else if interface == OutputDevice::interface().name {
|
||||||
|
let v = version.min(DEVICE_MAX);
|
||||||
|
// The device's `name` global carries into the device's UserData so the event
|
||||||
|
// handler can record it (newest-wins tie-break during a supersede).
|
||||||
|
let dev = registry.bind::<OutputDevice, _, _>(name, v, qh, name);
|
||||||
|
let id = dev.id();
|
||||||
|
state.devices.entry(id).or_default().proxy = Some(dev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wl_registry::Event::GlobalRemove { .. } => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Management has no events.
|
||||||
|
impl Dispatch<OutputManagement, ()> for State {
|
||||||
|
fn event(
|
||||||
|
_: &mut Self,
|
||||||
|
_: &OutputManagement,
|
||||||
|
_: management::kde_output_management_v2::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client-built custom-mode list has no events; it just needs a Dispatch impl to be created.
|
||||||
|
impl Dispatch<ModeList, ()> for State {
|
||||||
|
fn event(
|
||||||
|
_: &mut Self,
|
||||||
|
_: &ModeList,
|
||||||
|
_: management::kde_mode_list_v2::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The device's UserData is its global `name` number.
|
||||||
|
impl Dispatch<OutputDevice, u32> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
device: &OutputDevice,
|
||||||
|
event: DeviceEvent,
|
||||||
|
global: &u32,
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
let entry = state.devices.entry(device.id()).or_default();
|
||||||
|
entry.global = *global;
|
||||||
|
if entry.proxy.is_none() {
|
||||||
|
entry.proxy = Some(device.clone());
|
||||||
|
}
|
||||||
|
match event {
|
||||||
|
DeviceEvent::Name { name } => entry.name = Some(name),
|
||||||
|
DeviceEvent::Uuid { uuid } => entry.uuid = Some(uuid),
|
||||||
|
DeviceEvent::Enabled { enabled } => entry.enabled = enabled != 0,
|
||||||
|
DeviceEvent::Priority { priority } => entry.priority = Some(priority),
|
||||||
|
DeviceEvent::CurrentMode { mode } => entry.current_mode = Some(mode.id()),
|
||||||
|
DeviceEvent::Mode { mode } => entry.modes.push((mode.id(), mode)),
|
||||||
|
DeviceEvent::Done => entry.seen_done = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The `mode` event hands us a server-created `kde_output_device_mode_v2`. The opcode is a bare
|
||||||
|
// literal (the macro's fragment matcher rejects a `const` in some wayland-client versions); it is
|
||||||
|
// pinned to `DEVICE_MODE_EVENT_OPCODE` by `mode_event_opcode_is_two` below.
|
||||||
|
event_created_child!(State, OutputDevice, [
|
||||||
|
2 => (DeviceMode, ()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<DeviceMode, ()> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
mode: &DeviceMode,
|
||||||
|
event: ModeEvent,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
let entry = state.mode_dims.entry(mode.id()).or_insert((0, 0, 0));
|
||||||
|
match event {
|
||||||
|
ModeEvent::Size { width, height } => {
|
||||||
|
entry.0 = width.max(0) as u32;
|
||||||
|
entry.1 = height.max(0) as u32;
|
||||||
|
}
|
||||||
|
ModeEvent::Refresh { refresh } => entry.2 = refresh.max(0) as u32,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<OutputConfig, ()> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
_: &OutputConfig,
|
||||||
|
event: ConfigEvent,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
match event {
|
||||||
|
ConfigEvent::Applied => state.applied = Some(true),
|
||||||
|
ConfigEvent::Failed => state.applied = Some(false),
|
||||||
|
ConfigEvent::FailureReason { reason } => state.failure_reason = Some(reason),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<WlCallback, u32> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
_: &WlCallback,
|
||||||
|
event: wl_callback::Event,
|
||||||
|
serial: &u32,
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
if let wl_callback::Event::Done { .. } = event {
|
||||||
|
state.sync_done = state.sync_done.max(*serial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A connected, bound output-management session on its own Wayland connection.
|
||||||
|
struct Session {
|
||||||
|
conn: Connection,
|
||||||
|
queue: wayland_client::EventQueue<State>,
|
||||||
|
state: State,
|
||||||
|
next_sync: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
/// Connect to the KWin Wayland socket, bind `kde_output_management_v2` + every
|
||||||
|
/// `kde_output_device_v2`, and read each output's state — all bounded by `OP_BUDGET`. `None` if
|
||||||
|
/// we can't connect, the management global isn't advertised, or the compositor doesn't answer in
|
||||||
|
/// budget (the wedge case — the caller then falls back to `kscreen-doctor`).
|
||||||
|
fn open() -> Option<Session> {
|
||||||
|
let conn = Connection::connect_to_env().ok()?;
|
||||||
|
let queue = conn.new_event_queue();
|
||||||
|
let qh = queue.handle();
|
||||||
|
let _registry = conn.display().get_registry(&qh, ());
|
||||||
|
let mut s = Session {
|
||||||
|
conn,
|
||||||
|
queue,
|
||||||
|
state: State::default(),
|
||||||
|
next_sync: 0,
|
||||||
|
};
|
||||||
|
let deadline = Instant::now() + OP_BUDGET;
|
||||||
|
// Phase 1: process the registry globals (binds management + every device in the handler).
|
||||||
|
if !s.sync_barrier(deadline) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if s.state.management.is_none() {
|
||||||
|
tracing::debug!(
|
||||||
|
"KWin does not advertise kde_output_management_v2 to this client — kscreen-doctor \
|
||||||
|
fallback"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Phase 2: flush the device binds issued in phase 1 and drain each output's state burst
|
||||||
|
// (name / enabled / priority / current_mode / mode sizes / done).
|
||||||
|
if !s.sync_barrier(deadline) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a `wl_display.sync` and pump the queue until its `done` arrives or `deadline` passes.
|
||||||
|
/// Returns `true` on the barrier, `false` on timeout.
|
||||||
|
fn sync_barrier(&mut self, deadline: Instant) -> bool {
|
||||||
|
self.next_sync += 1;
|
||||||
|
let serial = self.next_sync;
|
||||||
|
let qh = self.queue.handle();
|
||||||
|
let _cb = self.conn.display().sync(&qh, serial);
|
||||||
|
self.pump_until(deadline, |st| st.sync_done >= serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded manual event loop: flush, dispatch what's queued, then poll the connection fd for up
|
||||||
|
/// to `POLL_MS` and read. Mirrors the keepalive loop in `kwin.rs::run` (blocking_dispatch can't
|
||||||
|
/// be interrupted, so we poll the fd instead). Returns `true` once `done(&state)` holds.
|
||||||
|
fn pump_until(&mut self, deadline: Instant, done: impl Fn(&State) -> bool) -> bool {
|
||||||
|
loop {
|
||||||
|
if done(&self.state) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if self.queue.dispatch_pending(&mut self.state).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if done(&self.state) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.conn.flush().is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(guard) = self.conn.prepare_read() else {
|
||||||
|
continue; // events already queued — loop dispatches them
|
||||||
|
};
|
||||||
|
let mut pfd = libc::pollfd {
|
||||||
|
fd: self.conn.as_fd().as_raw_fd(),
|
||||||
|
events: libc::POLLIN,
|
||||||
|
revents: 0,
|
||||||
|
};
|
||||||
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||||
|
let timeout = (remaining.as_millis() as i32).clamp(0, POLL_MS);
|
||||||
|
// SAFETY: `&mut pfd` points at one live, fully-initialized `libc::pollfd` on the stack and
|
||||||
|
// the count `1` matches that single element, so `poll` reads `fd`/`events` and writes
|
||||||
|
// `revents` strictly within `pfd`. `pfd.fd` is the Wayland connection's fd, valid because
|
||||||
|
// `self.conn` (and the `prepare_read` guard) outlive the call. `poll` blocks up to
|
||||||
|
// `timeout` ms and writes only `revents`; `pfd` is a fresh local that aliases nothing.
|
||||||
|
let r = unsafe { libc::poll(&mut pfd, 1, timeout) };
|
||||||
|
if r > 0 && (pfd.revents & libc::POLLIN) != 0 {
|
||||||
|
let _ = guard.read();
|
||||||
|
} // else: timeout/signal — drop the guard, re-check the deadline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh `kde_output_configuration_v2` on this connection.
|
||||||
|
fn new_config(&self) -> OutputConfig {
|
||||||
|
let qh = self.queue.handle();
|
||||||
|
self.state
|
||||||
|
.management
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.create_configuration(&qh, ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh (empty) `kde_mode_list_v2` on this connection, for a `set_custom_modes` request.
|
||||||
|
fn new_mode_list(&self) -> ModeList {
|
||||||
|
let qh = self.queue.handle();
|
||||||
|
self.state
|
||||||
|
.management
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.create_mode_list(&qh, ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `apply()` the config and pump until `applied`/`failed` or the deadline. Returns the verdict
|
||||||
|
/// (`true` applied, `false` failed/timeout).
|
||||||
|
fn apply(&mut self, config: &OutputConfig, deadline: Instant) -> bool {
|
||||||
|
self.state.applied = None;
|
||||||
|
config.apply();
|
||||||
|
let ok = self.pump_until(deadline, |st| st.applied.is_some());
|
||||||
|
if !ok {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
matches!(self.state.applied, Some(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current-mode size of a device as `(w, h, refresh_mHz)`, if known.
|
||||||
|
fn current_dims(&self, dev: &DeviceState) -> Option<(u32, u32, u32)> {
|
||||||
|
let id = dev.current_mode.as_ref()?;
|
||||||
|
self.state.mode_dims.get(id).copied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(width, height, "WxH@Hz")` capture of a device's current mode, Hz rounded — the same shape the
|
||||||
|
/// `kscreen-doctor` restore path used, so teardown can put a panel back at its real refresh.
|
||||||
|
fn mode_spec(dims: (u32, u32, u32)) -> String {
|
||||||
|
let hz = ((dims.2 as f64) / 1000.0).round() as u32;
|
||||||
|
format!("{}x{}@{}", dims.0, dims.1, hz)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefix EVERY managed KWin output shares (mirrors `kwin::MANAGED_PREFIX`) — the streamed outputs
|
||||||
|
/// are `Virtual-punktfunk` / `Virtual-punktfunk-<id>`, so a same-family sibling session is never
|
||||||
|
/// treated as a physical to disable, and its primary is never stolen (first-slot-wins).
|
||||||
|
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
|
||||||
|
|
||||||
|
/// Make the streamed output (name starts with `our_prefix`, current size `our_w`×`our_h`) the
|
||||||
|
/// primary — and, for `Exclusive`, disable every other enabled output — over `kde_output_management_v2`.
|
||||||
|
/// See the module docs for why this is done in-process instead of via `kscreen-doctor`.
|
||||||
|
pub(crate) fn apply_topology(
|
||||||
|
our_prefix: &str,
|
||||||
|
our_w: u32,
|
||||||
|
our_h: u32,
|
||||||
|
kind: TopologyKind,
|
||||||
|
) -> TopologyOutcome {
|
||||||
|
let miss = || TopologyOutcome {
|
||||||
|
our_uuid: None,
|
||||||
|
disabled: Vec::new(),
|
||||||
|
handled: false,
|
||||||
|
};
|
||||||
|
let Some(mut sess) = Session::open() else {
|
||||||
|
return miss();
|
||||||
|
};
|
||||||
|
let deadline = Instant::now() + OP_BUDGET;
|
||||||
|
|
||||||
|
// Resolve OUR output: managed-prefix name AND current size == the birth size (only the
|
||||||
|
// just-created output sits there during a supersede); newest global wins the tie.
|
||||||
|
let ours = sess
|
||||||
|
.state
|
||||||
|
.devices
|
||||||
|
.values()
|
||||||
|
.filter(|d| {
|
||||||
|
d.name.as_deref().is_some_and(|n| n.starts_with(our_prefix))
|
||||||
|
&& sess.current_dims(d).map(|(w, h, _)| (w, h)) == Some((our_w, our_h))
|
||||||
|
})
|
||||||
|
.max_by_key(|d| d.global)
|
||||||
|
.cloned();
|
||||||
|
let Some(ours) = ours else {
|
||||||
|
tracing::warn!(
|
||||||
|
our_prefix,
|
||||||
|
our_w,
|
||||||
|
our_h,
|
||||||
|
"KWin output management: our virtual output hasn't appeared yet — kscreen-doctor fallback"
|
||||||
|
);
|
||||||
|
return miss();
|
||||||
|
};
|
||||||
|
let our_uuid = ours.uuid.clone();
|
||||||
|
let our_id = ours.proxy.as_ref().map(|p| p.id());
|
||||||
|
|
||||||
|
// First-slot-wins (§6.1): don't steal primary if another managed sibling already holds it
|
||||||
|
// (priority 1) — a 2nd exclusive session joins as a secondary of the shared desktop.
|
||||||
|
let sibling_is_primary = sess.state.devices.values().any(|d| {
|
||||||
|
d.enabled
|
||||||
|
&& d.priority == Some(1)
|
||||||
|
&& d.proxy.as_ref().map(|p| p.id()) != our_id
|
||||||
|
&& d.name
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|n| n.starts_with(MANAGED_PREFIX))
|
||||||
|
});
|
||||||
|
|
||||||
|
// The physical/bootstrap outputs to disable for `Exclusive`: enabled, not any managed sibling,
|
||||||
|
// not ours. Captured WITH their current mode so teardown restores the exact refresh.
|
||||||
|
let mut to_disable: Vec<(OutputDevice, String, String)> = Vec::new();
|
||||||
|
if kind == TopologyKind::Exclusive {
|
||||||
|
for d in sess.state.devices.values() {
|
||||||
|
let is_ours = d.proxy.as_ref().map(|p| p.id()) == our_id;
|
||||||
|
let managed = d
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|n| n.starts_with(MANAGED_PREFIX));
|
||||||
|
if d.enabled && !is_ours && !managed {
|
||||||
|
if let (Some(name), Some(proxy)) = (d.name.clone(), d.proxy.clone()) {
|
||||||
|
let spec = sess.current_dims(d).map(mode_spec).unwrap_or_default();
|
||||||
|
to_disable.push((proxy, name, spec));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build one configuration: ensure ours is enabled, take primary (unless a sibling holds it),
|
||||||
|
// disable the others. `apply()` is atomic — KWin re-homes the shell onto the remaining desktop.
|
||||||
|
let config = sess.new_config();
|
||||||
|
if let Some(proxy) = ours.proxy.as_ref() {
|
||||||
|
config.enable(proxy, 1);
|
||||||
|
if !sibling_is_primary {
|
||||||
|
config.set_primary_output(proxy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (proxy, _, _) in &to_disable {
|
||||||
|
config.enable(proxy, 0);
|
||||||
|
}
|
||||||
|
let applied = sess.apply(&config, deadline);
|
||||||
|
config.destroy();
|
||||||
|
|
||||||
|
// Always report the outputs we ASKED to disable so teardown restores them: re-enabling an output
|
||||||
|
// that never actually got disabled is a harmless no-op, whereas dropping them here would strand a
|
||||||
|
// physical dark if KWin processed the disable but the `applied` ack didn't land in budget.
|
||||||
|
let disabled: Vec<(String, String)> = to_disable
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, name, spec)| (name, spec))
|
||||||
|
.collect();
|
||||||
|
if applied {
|
||||||
|
tracing::info!(
|
||||||
|
also_disabled = ?disabled,
|
||||||
|
primary_taken = !sibling_is_primary,
|
||||||
|
"KWin output management: streamed output set as the desktop (in-process)"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
reason = ?sess.state.failure_reason,
|
||||||
|
also_disabled = ?disabled,
|
||||||
|
"KWin output management: apply() not confirmed in budget — proceeding (restore will re-enable)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// We resolved our output and drove the config over Wayland; don't ALSO run kscreen-doctor — that
|
||||||
|
// would double-apply (and on a wedged box it would just add 26 s of timeouts). `handled` is true
|
||||||
|
// even on an unconfirmed apply; a genuinely absent management global / unresolved output took the
|
||||||
|
// `handled = false` early returns above and falls back to kscreen-doctor.
|
||||||
|
TopologyOutcome {
|
||||||
|
our_uuid,
|
||||||
|
disabled,
|
||||||
|
handled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install + select a `want_w`×`want_h`@`want_hz` custom mode on the just-created virtual output
|
||||||
|
/// (name starts with `our_prefix`, currently at its sacrificial birth size `birth_w`×`birth_h`) —
|
||||||
|
/// entirely over `kde_output_management_v2`, the in-process replacement for the `kscreen-doctor`
|
||||||
|
/// `addCustomMode` + `mode` shell-out (`set_custom_refresh`).
|
||||||
|
///
|
||||||
|
/// `set_custom_modes` hands KWin a one-entry mode list; KWin generates the CVT timing (so the width
|
||||||
|
/// may align DOWN — see [`CVT_H_GRANULARITY`]) and adds the mode. We then SELECT it, which changes
|
||||||
|
/// the output's size and triggers the screencast stream's renegotiation to the real refresh (the
|
||||||
|
/// sacrificial-birth mechanism in `kwin::create`). Returns the ACTIVE mode read back after selection
|
||||||
|
/// (Hz rounded), or `None` if management is absent, the output/generated mode never appeared, or an
|
||||||
|
/// apply didn't confirm — the caller then falls back to `kscreen-doctor`. `set_custom_modes` REPLACES
|
||||||
|
/// the custom list (idempotent across reconnects — no per-connect list growth), and it is `since 18`,
|
||||||
|
/// so pre-6.6 KWin without it simply takes the `None` → kscreen-doctor path.
|
||||||
|
pub(crate) fn set_custom_mode(
|
||||||
|
our_prefix: &str,
|
||||||
|
birth_w: u32,
|
||||||
|
birth_h: u32,
|
||||||
|
want_w: u32,
|
||||||
|
want_h: u32,
|
||||||
|
want_hz: u32,
|
||||||
|
) -> Option<(u32, u32, u32)> {
|
||||||
|
let mut sess = Session::open()?;
|
||||||
|
let deadline = Instant::now() + OP_BUDGET;
|
||||||
|
|
||||||
|
// `set_custom_modes` is `since 18`; calling it on an older bound management object is a protocol
|
||||||
|
// error, so bail to the kscreen-doctor fallback there (pre-6.6 KWin). Our bound version is
|
||||||
|
// `min(advertised, MGMT_MAX)`.
|
||||||
|
if sess.state.mgmt_name_version.map(|(_, v)| v).unwrap_or(0) < 18 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve our output at its birth size (newest global wins a supersede).
|
||||||
|
let our_proxy = sess
|
||||||
|
.state
|
||||||
|
.devices
|
||||||
|
.values()
|
||||||
|
.filter(|d| {
|
||||||
|
d.name.as_deref().is_some_and(|n| n.starts_with(our_prefix))
|
||||||
|
&& sess.current_dims(d).map(|(w, h, _)| (w, h)) == Some((birth_w, birth_h))
|
||||||
|
})
|
||||||
|
.max_by_key(|d| d.global)
|
||||||
|
.and_then(|d| d.proxy.clone())?;
|
||||||
|
let our_key = our_proxy.id();
|
||||||
|
|
||||||
|
let want_mhz = want_hz.saturating_mul(1000);
|
||||||
|
// A generated mode IS our custom one iff: exact height, width at/just-below the request (a CVT
|
||||||
|
// alignment), and refresh within 1 Hz — which excludes the sacrificial 60 Hz birth mode.
|
||||||
|
let mode_matches = move |st: &State, mid: &ObjectId| -> bool {
|
||||||
|
st.mode_dims.get(mid).is_some_and(|&(w, h, mhz)| {
|
||||||
|
h == want_h
|
||||||
|
&& w <= want_w
|
||||||
|
&& want_w - w < CVT_H_GRANULARITY
|
||||||
|
&& (mhz as i64 - want_mhz as i64).abs() <= 1000
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a one-entry custom-mode list (full blanking, like kscreen-doctor's `.full`) and install it.
|
||||||
|
let mode_list = sess.new_mode_list();
|
||||||
|
mode_list.set_resolution(want_w, want_h);
|
||||||
|
mode_list.set_refresh_rate(want_mhz);
|
||||||
|
mode_list.set_reduced_blanking(0);
|
||||||
|
mode_list.add_mode();
|
||||||
|
let config = sess.new_config();
|
||||||
|
config.set_custom_modes(&our_proxy, &mode_list);
|
||||||
|
let installed = sess.apply(&config, deadline);
|
||||||
|
config.destroy();
|
||||||
|
mode_list.destroy();
|
||||||
|
if !installed {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for KWin to generate the mode and advertise it on the output.
|
||||||
|
let found = |st: &State| -> bool {
|
||||||
|
st.devices
|
||||||
|
.get(&our_key)
|
||||||
|
.is_some_and(|d| d.modes.iter().any(|(mid, _)| mode_matches(st, mid)))
|
||||||
|
};
|
||||||
|
if !sess.pump_until(deadline, found) {
|
||||||
|
tracing::warn!(
|
||||||
|
want_w,
|
||||||
|
want_h,
|
||||||
|
want_hz,
|
||||||
|
"KWin output management: generated custom mode never appeared — kscreen-doctor fallback"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grab the generated mode's proxy, then select it (this is what changes the size).
|
||||||
|
let mode_proxy = {
|
||||||
|
let dev = sess.state.devices.get(&our_key)?;
|
||||||
|
dev.modes
|
||||||
|
.iter()
|
||||||
|
.find(|(mid, _)| mode_matches(&sess.state, mid))
|
||||||
|
.map(|(_, p)| p.clone())?
|
||||||
|
};
|
||||||
|
let config = sess.new_config();
|
||||||
|
config.mode(&our_proxy, &mode_proxy);
|
||||||
|
let selected = sess.apply(&config, deadline);
|
||||||
|
config.destroy();
|
||||||
|
if !selected {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back the active mode after selection — the size that really landed paces the encoder.
|
||||||
|
let want_dims = sess
|
||||||
|
.state
|
||||||
|
.mode_dims
|
||||||
|
.get(&mode_proxy.id())
|
||||||
|
.map(|&(w, h, _)| (w, h));
|
||||||
|
let landed = |st: &State| -> bool {
|
||||||
|
st.devices
|
||||||
|
.get(&our_key)
|
||||||
|
.and_then(|d| d.current_mode.as_ref())
|
||||||
|
.and_then(|mid| st.mode_dims.get(mid))
|
||||||
|
.map(|&(w, h, _)| (w, h))
|
||||||
|
== want_dims
|
||||||
|
};
|
||||||
|
sess.pump_until(deadline, landed);
|
||||||
|
let dev = sess.state.devices.get(&our_key)?;
|
||||||
|
let (cw, ch, cmhz) = sess.current_dims(dev)?;
|
||||||
|
let hz = ((cmhz as f64) / 1000.0).round() as u32;
|
||||||
|
tracing::info!(
|
||||||
|
want_w,
|
||||||
|
want_h,
|
||||||
|
want_hz,
|
||||||
|
active_w = cw,
|
||||||
|
active_h = ch,
|
||||||
|
active_hz = hz,
|
||||||
|
"KWin output management: custom mode installed + selected (in-process)"
|
||||||
|
);
|
||||||
|
Some((cw, ch, hz.max(1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-enable outputs by name at their captured `WxH@Hz` modes (teardown), in-process. Returns
|
||||||
|
/// `true` if the config applied; `false` (compositor unresponsive / management absent) tells the
|
||||||
|
/// caller to fall back to `kscreen-doctor`.
|
||||||
|
pub(crate) fn reenable_outputs(outputs: &[(String, String)]) -> bool {
|
||||||
|
if outputs.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Some(mut sess) = Session::open() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let deadline = Instant::now() + OP_BUDGET;
|
||||||
|
let config = sess.new_config();
|
||||||
|
for (name, spec) in outputs {
|
||||||
|
// Find the device by name (physical names are stable across a session).
|
||||||
|
let Some(dev) = sess
|
||||||
|
.state
|
||||||
|
.devices
|
||||||
|
.values()
|
||||||
|
.find(|d| d.name.as_deref() == Some(name.as_str()))
|
||||||
|
.cloned()
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(proxy) = dev.proxy.as_ref() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Enable first — a bare enable always succeeds, so a physical is never left dark.
|
||||||
|
config.enable(proxy, 1);
|
||||||
|
// Then re-assert the captured mode so a 120 Hz panel doesn't return at KWin's ~60 Hz default.
|
||||||
|
if let Some(mode) = find_mode(&sess, &dev, spec) {
|
||||||
|
config.mode(proxy, &mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ok = sess.apply(&config, deadline);
|
||||||
|
config.destroy();
|
||||||
|
if ok {
|
||||||
|
tracing::info!(reenabled = ?outputs, "KWin output management: restored outputs (in-process)");
|
||||||
|
}
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Position the output identified by `uuid` at `(x, y)` in the desktop layout, in-process. Returns
|
||||||
|
/// `true` if applied; `false` tells the caller to fall back to `kscreen-doctor`.
|
||||||
|
pub(crate) fn set_position(uuid: &str, x: i32, y: i32) -> bool {
|
||||||
|
let Some(mut sess) = Session::open() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let deadline = Instant::now() + OP_BUDGET;
|
||||||
|
let Some(dev) = sess
|
||||||
|
.state
|
||||||
|
.devices
|
||||||
|
.values()
|
||||||
|
.find(|d| d.uuid.as_deref() == Some(uuid))
|
||||||
|
.cloned()
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(proxy) = dev.proxy.as_ref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let config = sess.new_config();
|
||||||
|
config.position(proxy, x, y);
|
||||||
|
let ok = sess.apply(&config, deadline);
|
||||||
|
config.destroy();
|
||||||
|
if ok {
|
||||||
|
tracing::info!(
|
||||||
|
uuid,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
"KWin output management: placed output (in-process)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a device's advertised mode proxy matching a captured `"WxH@Hz"` spec (Hz rounded), for
|
||||||
|
/// restore. `None` if the spec is empty or no mode matches (the caller then enables without a mode
|
||||||
|
/// and lets KWin pick its preferred one).
|
||||||
|
fn find_mode(sess: &Session, dev: &DeviceState, spec: &str) -> Option<DeviceMode> {
|
||||||
|
if spec.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (wh, hz) = spec.split_once('@')?;
|
||||||
|
let (w, h) = wh.split_once('x')?;
|
||||||
|
let (w, h, hz): (u32, u32, u32) = (w.parse().ok()?, h.parse().ok()?, hz.parse().ok()?);
|
||||||
|
dev.modes.iter().find_map(|(id, proxy)| {
|
||||||
|
let (mw, mh, mmhz) = sess.state.mode_dims.get(id).copied()?;
|
||||||
|
let mhz = ((mmhz as f64) / 1000.0).round() as u32;
|
||||||
|
((mw, mh, mhz) == (w, h, hz)).then(|| proxy.clone())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The `WxH@Hz` capture rounds mHz to whole Hz — the shape teardown parses back.
|
||||||
|
#[test]
|
||||||
|
fn mode_spec_rounds_millihertz() {
|
||||||
|
assert_eq!(mode_spec((2560, 1440, 59940)), "2560x1440@60");
|
||||||
|
assert_eq!(mode_spec((1920, 1080, 60000)), "1920x1080@60");
|
||||||
|
assert_eq!(mode_spec((3840, 2160, 119880)), "3840x2160@120");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The vendored device XML must keep `mode` at the opcode the `event_created_child!` macro
|
||||||
|
/// hardcodes — a reorder there would bind the child to the wrong event and desync mode sizes.
|
||||||
|
#[test]
|
||||||
|
fn mode_event_opcode_is_two() {
|
||||||
|
assert_eq!(DEVICE_MODE_EVENT_OPCODE, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -939,7 +939,7 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
|||||||
// live topology each attempt and re-apply until ONLY the keep set is active. Secure-desktop
|
// live topology each attempt and re-apply until ONLY the keep set is active. Secure-desktop
|
||||||
// correctness depends on this — the lock screen must not land on a stray panel while we stream.
|
// correctness depends on this — the lock screen must not land on a stray panel while we stream.
|
||||||
for attempt in 1..=4u32 {
|
for attempt in 1..=4u32 {
|
||||||
let (mut paths, modes) = query_active_config()?;
|
let (mut paths, mut modes) = query_active_config()?;
|
||||||
let mut others = 0u32;
|
let mut others = 0u32;
|
||||||
for p in paths.iter_mut() {
|
for p in paths.iter_mut() {
|
||||||
if keep_target_ids.contains(&p.targetInfo.id) {
|
if keep_target_ids.contains(&p.targetInfo.id) {
|
||||||
@@ -961,6 +961,13 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
|||||||
others += 1;
|
others += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The doomed display may have HELD the desktop origin (the physical is primary in the
|
||||||
|
// single-slot exclusive case): re-anchor the kept sources so the supplied config still
|
||||||
|
// contains a primary — an origin-less desktop is rejected 0x57 no matter which array
|
||||||
|
// shape carries it (see `anchor_kept_sources_at_origin`).
|
||||||
|
if others > 0 {
|
||||||
|
anchor_kept_sources_at_origin(&paths, &mut modes);
|
||||||
|
}
|
||||||
// Commit the config. Even when nothing needed deactivating we re-commit: a legacy mode-set does
|
// Commit the config. Even when nothing needed deactivating we re-commit: a legacy mode-set does
|
||||||
// NOT drive the IddCx adapter's EVT_IDD_CX_ADAPTER_COMMIT_MODES, and without COMMIT_MODES the OS
|
// NOT drive the IddCx adapter's EVT_IDD_CX_ADAPTER_COMMIT_MODES, and without COMMIT_MODES the OS
|
||||||
// never calls ASSIGN_SWAPCHAIN, so the driver receives no frames. SDC_FORCE_MODE_ENUMERATION
|
// never calls ASSIGN_SWAPCHAIN, so the driver receives no frames. SDC_FORCE_MODE_ENUMERATION
|
||||||
@@ -969,11 +976,11 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
|||||||
// The supplied shape is decided (and its escalation announced) ONCE, outside the write, so a
|
// The supplied shape is decided (and its escalation announced) ONCE, outside the write, so a
|
||||||
// wrong-desktop retry re-issues the identical config instead of logging the escalation twice.
|
// wrong-desktop retry re-issues the identical config instead of logging the escalation twice.
|
||||||
let keep_only = (others > 0 && attempt >= 2).then(|| {
|
let keep_only = (others > 0 && attempt >= 2).then(|| {
|
||||||
// ESCALATION (attempt 2+): supply ONLY the keep paths. Field-reported (AMD +
|
// ESCALATION (attempt 2+): supply ONLY the keep paths. Kept as belt-and-braces —
|
||||||
// pf-vdisplay): carrying the doomed path in the array — inactive, modes unpinned —
|
// the field 0x57 this was built for turned out to be the missing desktop origin
|
||||||
// gets the whole config rejected 0x57 on EVERY retry, so the loop alone never
|
// (see `anchor_kept_sources_at_origin`), which rejected BOTH shapes identically;
|
||||||
// converged; the same host applies the keep-only shape rc=0 whenever the topology
|
// but should some other validator still choke on the full array, the minimal
|
||||||
// database has already made the virtual display sole. The final attempt also drops
|
// shape is the best last word. The final attempt also drops
|
||||||
// SDC_FORCE_MODE_ENUMERATION in case the driver rejects it combined with a real
|
// SDC_FORCE_MODE_ENUMERATION in case the driver rejects it combined with a real
|
||||||
// topology change — an actual path removal drives COMMIT_MODES on its own, so the
|
// topology change — an actual path removal drives COMMIT_MODES on its own, so the
|
||||||
// re-commit rationale doesn't need the flag here.
|
// re-commit rationale doesn't need the flag here.
|
||||||
@@ -1023,7 +1030,18 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
|||||||
tracing::warn!("display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying");
|
tracing::warn!("display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying");
|
||||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||||
}
|
}
|
||||||
tracing::error!("display isolate (CCD): failed to isolate target set {keep_target_ids:?} after 4 attempts — a non-virtual display stayed active (field-reported exclusive-mode bug)");
|
// Name the survivors instead of assuming their kind — the field logs showed this path fire
|
||||||
|
// with a sibling VIRTUAL display as the survivor (linger-expiry shrink), where the old
|
||||||
|
// "a non-virtual display stayed active" wording sent the triage the wrong way.
|
||||||
|
let survivors: Vec<String> = target_inventory()
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.active && !keep_target_ids.contains(&t.target_id))
|
||||||
|
.map(|t| format!("{} {} \"{}\"", t.target_id, t.tech, t.friendly))
|
||||||
|
.collect();
|
||||||
|
tracing::error!(
|
||||||
|
"display isolate (CCD): failed to isolate target set {keep_target_ids:?} after 4 attempts — still active: [{}] (field-reported exclusive-mode bug)",
|
||||||
|
survivors.join(", ")
|
||||||
|
);
|
||||||
Some(saved)
|
Some(saved)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1084,6 +1102,60 @@ fn remap_mode_idx(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A committable desktop must still contain a PRIMARY — a source pinned exactly at the origin
|
||||||
|
/// `(0,0)`. Deactivating the display that held the origin (the physical, in the exclusive
|
||||||
|
/// topology) while the kept virtual stays pinned at its EXTEND offset supplies an origin-less
|
||||||
|
/// desktop, and Windows rejects that wholesale with 0x57 ERROR_INVALID_PARAMETER no matter the
|
||||||
|
/// array shape — the field box failed identically with the doomed path carried inactive AND with
|
||||||
|
/// the keep-only escalation, yet the very same call converged rc=0 whenever a kept member already
|
||||||
|
/// sat at `(0,0)`; the origin was the real variable all along. Translate the kept sources RIGIDLY
|
||||||
|
/// (relative arrangement preserved) so the top-left-most lands exactly on the origin. A set that
|
||||||
|
/// already covers `(0,0)` is left untouched, so a plain re-commit stays byte-identical and a
|
||||||
|
/// user's negative-coordinate multi-monitor layout is never rearranged.
|
||||||
|
unsafe fn anchor_kept_sources_at_origin(
|
||||||
|
paths: &[DISPLAYCONFIG_PATH_INFO],
|
||||||
|
modes: &mut [DISPLAYCONFIG_MODE_INFO],
|
||||||
|
) {
|
||||||
|
// Unique source-mode entries of the still-ACTIVE (kept) paths — clone-style pairs share one,
|
||||||
|
// and a shared entry must be translated once.
|
||||||
|
let mut idxs: Vec<usize> = Vec::new();
|
||||||
|
for p in paths {
|
||||||
|
if p.flags & DISPLAYCONFIG_PATH_ACTIVE == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let idx = p.sourceInfo.Anonymous.modeInfoIdx as usize;
|
||||||
|
let Some(m) = modes.get(idx) else { continue };
|
||||||
|
if m.infoType == DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE && !idxs.contains(&idx) {
|
||||||
|
idxs.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let positions: Vec<(i32, i32)> = idxs
|
||||||
|
.iter()
|
||||||
|
.map(|&i| {
|
||||||
|
let pos = modes[i].Anonymous.sourceMode.position;
|
||||||
|
(pos.x, pos.y)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if positions.contains(&(0, 0)) {
|
||||||
|
return; // the kept set already holds the primary — don't touch a valid layout
|
||||||
|
}
|
||||||
|
// Lexicographic min over the actual positions — the anchor IS one of the kept sources, so
|
||||||
|
// after translation one source sits exactly at (0,0), which is what the validator wants.
|
||||||
|
let Some((ax, ay)) = positions.iter().copied().min() else {
|
||||||
|
return; // no pinned kept sources — placement is the OS's (SDC_ALLOW_CHANGES), nothing to anchor
|
||||||
|
};
|
||||||
|
for &i in &idxs {
|
||||||
|
let sm = &mut modes[i].Anonymous.sourceMode;
|
||||||
|
sm.position.x -= ax;
|
||||||
|
sm.position.y -= ay;
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
"display isolate (CCD): kept source(s) re-anchored onto the desktop origin (primary) — the doomed display held (0,0) delta=({},{})",
|
||||||
|
-ax,
|
||||||
|
-ay
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The desktop-space rectangle `(x, y, w, h)` of `target_id`'s SOURCE — where this display's
|
/// The desktop-space rectangle `(x, y, w, h)` of `target_id`'s SOURCE — where this display's
|
||||||
/// region lives in the desktop coordinate space. `None` while the target isn't an active path.
|
/// region lives in the desktop coordinate space. `None` while the target isn't an active path.
|
||||||
/// Used by the IDD-push compose kick to dirty THE TARGET display: with parallel displays the
|
/// Used by the IDD-push compose kick to dirty THE TARGET display: with parallel displays the
|
||||||
@@ -1316,4 +1388,22 @@ pub unsafe fn restore_displays_ccd(saved: &SavedConfig) {
|
|||||||
sdc_access_denied_hint(rc)
|
sdc_access_denied_hint(rc)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// GUARANTEE the desk is never left all-dark. The saved config can be unappliable (field
|
||||||
|
// rc=0x64a ERROR_BAD_CONFIGURATION: it pinned a virtual target incarnation that was since
|
||||||
|
// removed) or even apply rc=0 yet re-light nothing (snapshotted while an earlier failed
|
||||||
|
// teardown had the physicals off — the poisoned-snapshot chain from the field logs). Either
|
||||||
|
// way, if no external physical panel is active after the apply while at least one is
|
||||||
|
// connected, fall back to the OS database EXTEND preset, which re-activates every connected
|
||||||
|
// display. Internal panels deliberately don't count as lit-able here — a closed clamshell
|
||||||
|
// lid must not be forced back on.
|
||||||
|
let (connected, lit) = target_inventory()
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.external_physical)
|
||||||
|
.fold((0u32, 0u32), |(c, a), t| (c + 1, a + u32::from(t.active)));
|
||||||
|
if connected > 0 && lit == 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
"display isolate (CCD): no external physical display active after the restore (rc={rc:#x}, connected={connected}) — forcing the EXTEND preset so the desk is not left dark"
|
||||||
|
);
|
||||||
|
force_extend_topology();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ pub fn capture_virtual_output(
|
|||||||
virtual-display warnings above)"
|
virtual-display warnings above)"
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
// Aim the injectors' absolute mapping (pen/touch/abs-mouse) at THIS display: the wire
|
||||||
|
// normalizes over the streamed frame, and mapping it over the whole virtual desktop is wrong
|
||||||
|
// the moment a physical monitor shares the desktop (Extend topology, or an Exclusive isolate
|
||||||
|
// degraded to the keep-physicals fallback) — the pen-offset field bug.
|
||||||
|
crate::inject::set_stream_target(Some(target.target_id));
|
||||||
let pref = vout.preferred_mode;
|
let pref = vout.preferred_mode;
|
||||||
let keep = vout.keepalive;
|
let keep = vout.keepalive;
|
||||||
// The sealed-channel delivery seam: resolve the pf-vdisplay control device ONCE (it is
|
// The sealed-channel delivery seam: resolve the pf-vdisplay control device ONCE (it is
|
||||||
|
|||||||
@@ -1940,6 +1940,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// connected, frozen on the last frame, and the stream resumes when the new output
|
// connected, frozen on the last frame, and the stream resumes when the new output
|
||||||
// appears — no reconnect.
|
// appears — no reconnect.
|
||||||
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
|
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
|
||||||
|
// A managed/attach gamescope (re)launch legitimately takes up to 45 s — the Steam
|
||||||
|
// Big Picture cold start that `launch_session`/`ensure_box_gamescope_mode` poll
|
||||||
|
// for — so the 40 s budget used to expire INSIDE the first attempt (a single-shot
|
||||||
|
// failure ending the session even when a second, warm attempt would have
|
||||||
|
// succeeded). Give gamescope-targeted rebuilds room for two full launch attempts;
|
||||||
|
// desktop compositors keep the tighter budget. Checked per iteration because the
|
||||||
|
// loop retargets `compositor` as re-detection follows the box.
|
||||||
|
const GAMESCOPE_REBUILD_BUDGET: std::time::Duration =
|
||||||
|
std::time::Duration::from_secs(100);
|
||||||
// Attach-only holdoff: for the first seconds after a capture loss the session
|
// Attach-only holdoff: for the first seconds after a capture loss the session
|
||||||
// detection can be STALE (the new session isn't up yet), and a rebuild acting on
|
// detection can be STALE (the new session isn't up yet), and a rebuild acting on
|
||||||
// a stale "Gaming" answer restarts gamescope-session.target — which on SteamOS
|
// a stale "Gaming" answer restarts gamescope-session.target — which on SteamOS
|
||||||
@@ -1948,7 +1957,24 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// scope: attach to live outputs only, never stop/relaunch/take over sessions.
|
// scope: attach to live outputs only, never stop/relaunch/take over sessions.
|
||||||
const PROBE_HOLDOFF: std::time::Duration = std::time::Duration::from_secs(4);
|
const PROBE_HOLDOFF: std::time::Duration = std::time::Duration::from_secs(4);
|
||||||
let loss_at = std::time::Instant::now();
|
let loss_at = std::time::Instant::now();
|
||||||
let rebuild_deadline = loss_at + REBUILD_BUDGET;
|
// An explicit PUNKTFUNK_COMPOSITOR pin disables the re-detection below — the
|
||||||
|
// stream cannot follow a session switch. When the live session no longer matches
|
||||||
|
// the pin, say so loudly ONCE per loss: this rebuild can only retry the pinned
|
||||||
|
// backend and will die at the budget (the "mid-stream switch to game mode kills
|
||||||
|
// the stream" field reports all traced back to a stale pin).
|
||||||
|
if pf_host_config::config().compositor.is_some() {
|
||||||
|
let active = crate::vdisplay::detect_active_session();
|
||||||
|
if crate::vdisplay::compositor_for_kind(active.kind) != Some(compositor) {
|
||||||
|
tracing::warn!(
|
||||||
|
pinned = compositor.id(),
|
||||||
|
live = ?active.kind,
|
||||||
|
"capture lost while PUNKTFUNK_COMPOSITOR pins the backend and the \
|
||||||
|
live session no longer matches it — the pin disables \
|
||||||
|
session-following, so this rebuild can only retry the pinned \
|
||||||
|
backend; remove the pin to let the stream follow session switches"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = loop {
|
let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = loop {
|
||||||
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
|
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
|
||||||
// retargeting (then we stick to the pinned backend and just rebuild it).
|
// retargeting (then we stick to the pinned backend and just rebuild it).
|
||||||
@@ -2021,8 +2047,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
) {
|
) {
|
||||||
Ok(p) => break p,
|
Ok(p) => break p,
|
||||||
Err(e2) => {
|
Err(e2) => {
|
||||||
|
let budget = if compositor == crate::vdisplay::Compositor::Gamescope {
|
||||||
|
GAMESCOPE_REBUILD_BUDGET
|
||||||
|
} else {
|
||||||
|
REBUILD_BUDGET
|
||||||
|
};
|
||||||
if stop.load(Ordering::SeqCst)
|
if stop.load(Ordering::SeqCst)
|
||||||
|| std::time::Instant::now() >= rebuild_deadline
|
|| std::time::Instant::now() >= loss_at + budget
|
||||||
{
|
{
|
||||||
return Err(e2)
|
return Err(e2)
|
||||||
.context("capture lost — no compositor came up within the rebuild budget");
|
.context("capture lost — no compositor came up within the rebuild budget");
|
||||||
|
|||||||
@@ -100,8 +100,9 @@ For Gaming Mode there are two models (pick one; the shipped default is **attach*
|
|||||||
|
|
||||||
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`, the template's default) — the **box** owns its
|
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`, the template's default) — the **box** owns its
|
||||||
gamescope session on its own display, and the host attaches to whatever's live without ever
|
gamescope session on its own display, and the host attaches to whatever's live without ever
|
||||||
tearing it down (a box-owned autologin session is restarted at the client's resolution on a
|
tearing it down (on a headless box, a box-owned autologin session is restarted at the client's
|
||||||
mismatch). Switching Desktop ↔ Game is rock-solid.
|
resolution on a mismatch; with a display connected it streams at the box's own mode). Switching
|
||||||
|
Desktop ↔ Game is rock-solid.
|
||||||
- **Managed** (`PUNKTFUNK_GAMESCOPE_MANAGED=1`, and remove the attach line) — the host takes the
|
- **Managed** (`PUNKTFUNK_GAMESCOPE_MANAGED=1`, and remove the attach line) — the host takes the
|
||||||
box's gamescope over and relaunches it **headless** at the *client's* exact resolution and
|
box's gamescope over and relaunches it **headless** at the *client's* exact resolution and
|
||||||
refresh — Game Mode on the virtual screen — restoring the box on idle.
|
refresh — Game Mode on the virtual screen — restoring the box on idle.
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ the full picture (and [Bazzite](/docs/bazzite) for that distro's specifics).
|
|||||||
|
|
||||||
| Setting | Values | Meaning |
|
| Setting | Values | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session on its own display (you switch Gaming ↔ Desktop with the Steam UI); the host just captures whatever's live and never tears it down. A box-owned autologin session is restarted at the client's resolution on a mismatch; a foreign/bare gamescope streams at its own mode. |
|
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session on its own display (you switch Gaming ↔ Desktop with the Steam UI); the host just captures whatever's live and never tears it down. On a **headless** box the box-owned autologin session is restarted at the client's resolution on a mismatch; a box driving a physical display, and any foreign/bare gamescope, streams at its own mode. |
|
||||||
| `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model (the default where session infra is detected): the host takes the box's gamescope over and relaunches it **headless** at the *client's* exact resolution — Game Mode on the virtual screen — restoring the box on idle. |
|
| `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model (the default where session infra is detected): the host takes the box's gamescope over and relaunches it **headless** at the *client's* exact resolution — Game Mode on the virtual screen — restoring the box on idle. |
|
||||||
| `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode (headless appliance; no physical session running). |
|
| `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode (headless appliance; no physical session running). |
|
||||||
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover + capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
|
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover + capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
|
||||||
|
|||||||
@@ -24,14 +24,46 @@ that has gamescope session infrastructure (Bazzite, SteamOS, Nobara) gets **mana
|
|||||||
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`) — the **box** owns its gamescope session and decides
|
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`) — the **box** owns its gamescope session and decides
|
||||||
Gaming vs Desktop via the normal Steam UI. Game Mode stays on the box's own (physical) display;
|
Gaming vs Desktop via the normal Steam UI. Game Mode stays on the box's own (physical) display;
|
||||||
the host attaches to whatever's live and never tears it down, so switching Desktop ↔ Game is
|
the host attaches to whatever's live and never tears it down, so switching Desktop ↔ Game is
|
||||||
rock-solid and disconnecting leaves the box where it was. When the session is the box's own
|
rock-solid and disconnecting leaves the box where it was. When the box is **headless** (no
|
||||||
autologin unit, the host restarts it at the **client's** resolution on a mismatch; a foreign or
|
display connected) and the session is its own autologin unit, the host restarts it at the
|
||||||
bare gamescope is streamed at its own mode.
|
**client's** resolution on a mismatch; a box driving a physical display — and any foreign or
|
||||||
|
bare gamescope — is streamed at its own mode.
|
||||||
- **Managed** (the infra-detected default; force with `PUNKTFUNK_GAMESCOPE_MANAGED=1`) — the host
|
- **Managed** (the infra-detected default; force with `PUNKTFUNK_GAMESCOPE_MANAGED=1`) — the host
|
||||||
takes the box's gamescope session over and relaunches it **headless** at the *client's* exact
|
takes the box's gamescope session over and relaunches it **headless** at the *client's* exact
|
||||||
resolution and refresh — Game Mode runs on the virtual screen, physical displays drop out of it —
|
resolution and refresh — Game Mode runs on the virtual screen, physical displays drop out of it —
|
||||||
restoring the box on idle after disconnect.
|
restoring the box on idle after disconnect.
|
||||||
|
|
||||||
|
### Nobara and other autologin display managers
|
||||||
|
|
||||||
|
The managed takeover has to stop the box's Gaming Mode session to free Steam. How it does that
|
||||||
|
depends on the display manager driving the autologin:
|
||||||
|
|
||||||
|
- **SDDM** (Bazzite, SteamOS): handled automatically — no setup.
|
||||||
|
- **plasmalogin** (Nobara) and other display managers: the host must stop the display manager
|
||||||
|
itself for the length of the stream and restart it afterwards, which needs privilege. Allow it
|
||||||
|
with a polkit rule (adjust the unit and user names to your box):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// /etc/polkit-1/rules.d/49-punktfunk-dm.rules
|
||||||
|
polkit.addRule(function(action, subject) {
|
||||||
|
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||||
|
action.lookup("unit") == "plasmalogin.service" &&
|
||||||
|
subject.user == "YOUR_USER") {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Without the rule the host degrades safely: it **attaches** to the live Gaming Mode session
|
||||||
|
instead (Game Mode stays on the box's display, mirrored to the client) rather than risk the
|
||||||
|
display manager. If the display-manager restart ever loses its privilege mid-restore,
|
||||||
|
`PUNKTFUNK_RECOVER_SESSION_CMD` (see [Configuration](/docs/configuration)) is fired as the
|
||||||
|
fallback.
|
||||||
|
|
||||||
|
With the rule in place the **in-stream session switch round-trips** in managed mode: Steam's
|
||||||
|
"Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session and the
|
||||||
|
stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
|
||||||
|
|
||||||
## Session following
|
## Session following
|
||||||
|
|
||||||
`PUNKTFUNK_SESSION_WATCH` follows a Gaming ↔ Desktop switch **mid-stream** — the host rebuilds the
|
`PUNKTFUNK_SESSION_WATCH` follows a Gaming ↔ Desktop switch **mid-stream** — the host rebuilds the
|
||||||
@@ -58,7 +90,7 @@ a model. See the full [Configuration reference](/docs/configuration) for every o
|
|||||||
|
|
||||||
| Setting | Values | Meaning |
|
| Setting | Values | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session (on its own display); the host captures whatever's live and never tears it down. A box-owned autologin session is restarted at the client's resolution on a mismatch; a foreign/bare gamescope streams at its own mode. |
|
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session (on its own display); the host captures whatever's live and never tears it down. On a **headless** box the box-owned autologin session is restarted at the client's resolution on a mismatch; a box driving a physical display, and any foreign/bare gamescope, streams at its own mode. |
|
||||||
| `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model (the default where session infra is detected): the host takes the box's gamescope over and relaunches it headless at the client's exact mode, restoring on idle. |
|
| `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model (the default where session infra is detected): the host takes the box's gamescope over and relaunches it headless at the client's exact mode, restoring on idle. |
|
||||||
| `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode — a headless appliance with no physical session running. |
|
| `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode — a headless appliance with no physical session running. |
|
||||||
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover and capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
|
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover and capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
|
||||||
|
|||||||
@@ -107,6 +107,21 @@ See [GNOME](/docs/gnome) for the GL/EGL userspace details.
|
|||||||
auto-detects the live compositor, and the pin points it at one backend even when a different
|
auto-detects the live compositor, and the pin points it at one backend even when a different
|
||||||
session is live (it also disables Gaming ↔ Desktop following).
|
session is live (it also disables Gaming ↔ Desktop following).
|
||||||
|
|
||||||
|
## The screen stays black after switching to Game Mode (Nobara)
|
||||||
|
|
||||||
|
On distros whose Game Mode is display-manager autologin under **plasmalogin** (Nobara), a managed
|
||||||
|
takeover from a host **0.19.1 or older** could kill the display manager: it trips systemd's start
|
||||||
|
limit and the box stays black until someone restarts it. Recover from a VT (Ctrl+Alt+F3) or SSH:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl --user unmask --runtime 'gamescope-session-plus@*.service'
|
||||||
|
sudo systemctl reset-failed plasmalogin && sudo systemctl restart plasmalogin
|
||||||
|
```
|
||||||
|
|
||||||
|
Current hosts detect the display-manager flavor and never mask the session unit there — see
|
||||||
|
[gamescope → autologin display managers](/docs/gamescope) for the polkit rule that enables the full
|
||||||
|
managed takeover on these boxes (without it the host mirrors Game Mode instead).
|
||||||
|
|
||||||
## Session fails right after editing host.env
|
## Session fails right after editing host.env
|
||||||
|
|
||||||
- Keys are **case-sensitive**: `punktfunk_gamescope_attach=1` sets nothing — use the exact
|
- Keys are **case-sensitive**: `punktfunk_gamescope_attach=1` sets nothing — use the exact
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
A small fixes update, all on the machine you stream *from* — your apps don't change, you can update whenever, and everything you've already paired stays paired.
|
||||||
|
|
||||||
|
This one cleans up a handful of things that went wrong on specific setups. On a **KDE Plasma** desktop, streaming now properly takes over the screen — switching off the real monitors and handing you the streamed display — and comes up in a couple of seconds instead of stalling, even on machines where KDE's own display tools had stopped responding. On a **Linux** machine you switch into **Steam's Game Mode**, that switch no longer risks leaving the machine stuck on a black screen, and you can now switch back to the desktop from inside the stream. And on **Windows**, giving the stream its own dedicated display is reliable again and can no longer leave the real desktop dark — and pen, touch and absolute-position mouse now land where you point when a second monitor is in play.
|
||||||
|
|
||||||
|
## Fixed: streaming from a KDE Plasma desktop takes over the screen — and comes up fast
|
||||||
|
|
||||||
|
On some KDE Plasma machines — a Nobara media PC among them — connecting used to *look* like it worked but leave the real monitors on and never actually hand you the streamed display, after a long wait of around half a minute. The cause was KDE's own display tooling hanging on that machine, with Punktfunk waiting on it.
|
||||||
|
|
||||||
|
Punktfunk now sets the machine's displays up by talking to KDE directly instead of going through that tool:
|
||||||
|
|
||||||
|
- **The streamed display actually becomes the desktop.** The physical monitors switch off for the session and come back when you disconnect — reliably, and typically in a couple of seconds instead of half a minute.
|
||||||
|
- **It works even when KDE's display settings tool is unresponsive** — the exact case that used to break connecting entirely.
|
||||||
|
- **High refresh rates work on these machines too.** The 90/120 Hz path no longer leans on that same tool. (Machines where the tool was healthy already worked; this just removes the dependency.)
|
||||||
|
|
||||||
|
## Fixed: switching a Linux machine into Steam's Game Mode no longer breaks it
|
||||||
|
|
||||||
|
Switching a Linux desktop into Steam's Game Mode mid-stream could leave the whole machine on a **permanent black screen** — on desktops that don't use the same sign-in manager as a Steam Deck (a Nobara home-theatre PC was the report). Steam Deck-style machines were never affected.
|
||||||
|
|
||||||
|
The switch is safe now: Punktfunk hands the screen to Game Mode without taking down the machine's sign-in manager, and puts everything back when you disconnect. On a machine where it doesn't have permission to do that cleanly, it mirrors whatever Game Mode is already showing rather than risk the screen — so you always get a picture instead of a black one.
|
||||||
|
|
||||||
|
## New: switch back to the desktop from inside the stream
|
||||||
|
|
||||||
|
On a Linux machine that Punktfunk put into Game Mode for you, choosing **Switch to Desktop** from Steam's menu used to do nothing — it just dropped you straight back into Game Mode. It now actually returns the machine to its desktop, and the stream follows it there.
|
||||||
|
|
||||||
|
## Fixed: giving the stream its own display on Windows is reliable again — and never goes dark
|
||||||
|
|
||||||
|
The **experimental option that isolates the stream onto its own display** on Windows (switching off the physical monitors) could fail outright on some monitor layouts, and a failed attempt could leave the real desktop on a black screen.
|
||||||
|
|
||||||
|
- **It works across monitor layouts now**, not only when a physical monitor happened to sit in the top-left corner of the desktop.
|
||||||
|
- **The desktop is never left dark.** If restoring your original layout can't light a screen back up, Punktfunk falls back to a normal extended-desktop arrangement, so you're never stranded looking at black.
|
||||||
|
|
||||||
|
## Fixed: pen, touch and absolute mouse land where you point on a multi-monitor Windows machine
|
||||||
|
|
||||||
|
When you stream from a Windows machine that keeps a **physical monitor on alongside** the streamed display, pen, touch, and absolute-position mouse input landed in the **wrong spot** — shifted across and scaled wrong, so a stylus drew where you weren't pointing. A single-display setup was never affected; the pen showed it first, because a stylus is pure point-where-you-touch with nothing to correct it.
|
||||||
|
|
||||||
|
Input now maps onto the streamed display itself, so **the pen lands exactly where you touch** — and it keeps up if you rearrange the machine's monitors mid-session.
|
||||||
|
|
||||||
|
## Under the hood (for developers)
|
||||||
|
|
||||||
|
- **Nothing changed on the wire or at the API boundary.** The streaming protocol stays at version **2** and the embeddable core library stays at C ABI **13** — no embedder rebuild is required, and 0.18/0.19 hosts and clients still mix freely. The Windows virtual-display driver protocol is unchanged.
|
||||||
|
- **KWin display topology now runs in-process over `kde_output_management_v2` / `kde_output_device_v2`, not `kscreen-doctor`.** `kscreen-doctor` drives libkscreen, which waits on the kscreen KDED module over D-Bus; when that layer wedges it blocks in its own connect and never returns, so all five topology queries hit their 5 s budget and are killed — the physicals never disable and the virtual output never becomes the desktop (`also_disabled=[]`, ~26 s bring-up) even though the compositor's own Wayland is fully responsive. The host now resolves its output by stable device UUID (supersede-robust), takes primary, disables the physical/bootstrap outputs (capturing their modes to re-enable on teardown) and positions — all over bounded Wayland round-trips (management v19 bound + outputs enumerated in ~2.4 ms on KWin 6.6.4, ~3400× faster than the hang). Every wait is time-bounded, so a genuinely wedged compositor degrades to `handled = false` and the old `kscreen-doctor` path still runs.
|
||||||
|
- **>60 Hz custom modes install in-process too**, so the whole KWin path is kscreen-free on modern KWin. `set_custom_mode` builds a one-entry `kde_mode_list_v2` and applies it via `kde_output_configuration_v2.set_custom_modes` (since v18), waits for KWin to generate the mode (its CVT generator may align the width down — matched with the same height-exact / width-within-8 / refresh-within-1 Hz gate as before), then selects it, which drives the sacrificial-birth stream renegotiation. `set_custom_modes` *replaces* the custom list, so reconnects are idempotent — no more one-custom-mode-per-connect growth of the display's mode list — and a >60 Hz session no longer eats a 5 s kscreen resolve timeout on a box where the tool wedges. Pre-6.6 KWin without `set_custom_modes` falls back to `set_custom_refresh`.
|
||||||
|
- **The gamescope managed takeover is display-manager-flavor-aware.** Masking the box's `gamescope-session-plus` unit made plasmalogin's `Relogin=true` fail its Exec repeatedly and trip systemd's start limit within ~1 s — the DM dies, and the restore verb (unmask + user start) cannot bring a seatless gamescope back; only `reset-failed` + `restart` recovers. SDDM (Bazzite/SteamOS) keeps the proven mask + SIGKILL path. plasmalogin / unknown DMs are never masked: with privilege (root or a scoped operator polkit rule on the DM unit — documented) the host stops the DM for the stream and restores it with `reset-failed` + `restart` (recorded in the persisted takeover state so a host crash still restores); without privilege the managed takeover degrades to attach and mirrors the live Game Mode. Companion fixes from the same triage: an attach-only rebuild-probe guard on `ensure_box_gamescope_mode` (which also no longer re-modes a box that drives a physical display), a 100 s capture-loss rebuild budget for gamescope (the 40 s budget expired inside Steam's first ~45 s cold-start attempt), a once-per-capture-loss WARN when a `PUNKTFUNK_COMPOSITOR` pin no longer matches the live session, and a managed session that took nothing over is stopped on disconnect instead of orphaned.
|
||||||
|
- **In-stream "Switch to Desktop" is honored under the managed takeover.** Steam's session-select is a silent no-op while the DM is stopped (its config-write branches all require the DM running), so it merely relaunched Game Mode. The managed launch now baselines the `~/.config/steamos-session-select` sentinel's mtime; a capture loss with the sentinel advanced is read as the switch request and replayed with the DM up — restore the DM, run the distro's own `os-session-select` (its internal pkexec is authorized `allow_any` by the distro policy, so it works from the host's sessionless context), then stop the autologin game-mode unit so Relogin lands in the newly selected desktop. A 120 s post-honor grace keeps the rebuild loop from racing the booting desktop back into Game Mode (superseded early if the box's own game-mode unit reappears, so the desktop→game leg stays fast). The baseline is re-recorded on crash-restore so a pre-existing sentinel never reads as a fresh request.
|
||||||
|
- **Windows absolute input maps over the streamed output's desktop rect, not the whole virtual desktop.** Pen, touch and absolute mouse arrive normalized to the streamed display's frame, but `pointer_windows::to_screen` and sendinput's `MouseMoveAbs` mapped them across the entire virtual desktop — correct only when the virtual display is the sole active display (Exclusive topology). In Extend — a physical kept on beside the virtual output, or an Exclusive isolate degraded to the `0x57` keep-physicals fallback — the streamed output sits at a non-zero origin, so every sample landed shifted and mis-scaled, and the strictly-absolute pen (no closed-loop correction onto the target) exposed it first. New `pf-inject::stream_target` publishes the streamed output's CCD target id at capture bring-up (one central site — `capture_virtual_output` covers the native and GameStream planes); mapping sites resolve its current desktop rect through `pf-win-display`'s `source_desktop_rect` — the same resolver the cursor-readback poller uses, so inject and readback always agree — TTL-cached at 250 ms because a group-layout re-arrange moves a live output's origin mid-session. No target resolved falls back to the whole virtual desktop (still right for Exclusive topology). The same fix closes the identical latent absolute-mouse offset.
|
||||||
|
- **Windows display isolation anchors the kept sources at the desktop origin.** A committable CCD config must contain a primary pinned at exactly (0,0); deactivating the display that held the origin while the kept virtual stays at its EXTEND offset supplies an origin-less desktop, which Windows rejects wholesale (`0x57`) — on every shape, doomed-path-carried and keep-only escalation alike. The kept sources are now translated rigidly so the top-left-most lands on the origin (sets already covering (0,0) stay byte-identical, so plain re-commits don't churn). `restore_displays_ccd` also guarantees the desktop is never left all-dark: an unappliable snapshot (`0x64a`) or one that applies cleanly yet re-lights nothing falls back to the database EXTEND preset when no external physical is active afterwards (internal panels don't count — a closed lid isn't forced back on), and the final isolate-failure diagnostic now names the surviving targets rather than asserting a non-virtual display stayed active.
|
||||||
Reference in New Issue
Block a user