Compare commits
@@ -438,3 +438,9 @@ jobs:
|
||||
echo "winget source serving ${GITHUB_REF_NAME#v}"
|
||||
env:
|
||||
GITHUB_REF_NAME: ${{ gitea.ref_name }}
|
||||
# `env:` populates the RUNNER's environment; this action runs `script` on the REMOTE
|
||||
# host, which inherits nothing from it. `envs:` is the action's own allow-list of names
|
||||
# to forward into the remote shell — without it `set -u` aborted at the first expansion
|
||||
# ("GITHUB_REF_NAME: unbound variable") and the step failed on every tag, after the
|
||||
# catalogue had already shipped correctly.
|
||||
envs: GITHUB_REF_NAME
|
||||
|
||||
Generated
+34
-27
@@ -1020,6 +1020,13 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.6"
|
||||
@@ -2194,7 +2201,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2299,7 +2306,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2334,7 +2341,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2823,7 +2830,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2844,7 +2851,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2868,7 +2875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2886,7 +2893,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2907,7 +2914,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2931,7 +2938,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2940,7 +2947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2952,7 +2959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2966,11 +2973,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2999,14 +3006,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3021,7 +3028,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3053,7 +3060,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3065,7 +3072,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3273,7 +3280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3289,7 +3296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3305,7 +3312,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3320,7 +3327,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3339,7 +3346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3371,7 +3378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3455,7 +3462,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3469,7 +3476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3492,7 +3499,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+2
-1
@@ -28,6 +28,7 @@ members = [
|
||||
"clients/session",
|
||||
"clients/windows",
|
||||
"clients/android/native",
|
||||
"tools/display-disturb",
|
||||
"tools/latency-probe",
|
||||
"tools/loss-harness",
|
||||
]
|
||||
@@ -48,7 +49,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+138
-2
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.19.2"
|
||||
"version": "0.20.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -190,6 +190,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/display/monitors": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"display"
|
||||
],
|
||||
"summary": "Physical monitors",
|
||||
"description": "The heads this host actually has — for pinning capture at one (`PUNKTFUNK_CAPTURE_MONITOR`) and\nfor rendering a picker. Read-only: this never creates, moves or disables anything. Note these\nare *not* the managed virtual displays — those are `/display/state`. See\n`design/per-monitor-portal-capture.md` §5.1.",
|
||||
"operationId": "getDisplayMonitors",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The host's physical monitors",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MonitorsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/display/presets": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -3646,6 +3678,66 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiMonitorInfo": {
|
||||
"type": "object",
|
||||
"description": "One physical monitor this host has, as the compositor reports it.",
|
||||
"required": [
|
||||
"connector",
|
||||
"description",
|
||||
"mode",
|
||||
"x",
|
||||
"y",
|
||||
"scale",
|
||||
"primary",
|
||||
"enabled",
|
||||
"managed",
|
||||
"selected"
|
||||
],
|
||||
"properties": {
|
||||
"connector": {
|
||||
"type": "string",
|
||||
"description": "Connector name (`DP-1`, `HDMI-A-2`) — the value `PUNKTFUNK_CAPTURE_MONITOR` takes."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Human label for a picker (`make model`, else the connector)."
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Driven right now. A disabled head is still listed, so it can be explained rather than missing."
|
||||
},
|
||||
"managed": {
|
||||
"type": "boolean",
|
||||
"description": "Best-effort: this is one of OUR virtual displays, not a real head (reliable on KWin only)."
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"description": "`WIDTHxHEIGHT@HZ` of the current mode (size only when the refresh is unknown)."
|
||||
},
|
||||
"primary": {
|
||||
"type": "boolean",
|
||||
"description": "The compositor's primary/focused head."
|
||||
},
|
||||
"scale": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "Logical scale factor."
|
||||
},
|
||||
"selected": {
|
||||
"type": "boolean",
|
||||
"description": "True when `PUNKTFUNK_CAPTURE_MONITOR` currently names this monitor."
|
||||
},
|
||||
"x": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Desktop-space top-left — what makes a head identifiable when two share a size."
|
||||
},
|
||||
"y": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiSelectedGpu": {
|
||||
"type": "object",
|
||||
"description": "The GPU the **next** session's pipeline will be created on, and why. (A preference change\napplies to the next session; a running session keeps the GPU it opened on.)",
|
||||
@@ -4251,6 +4343,13 @@
|
||||
"type": "object",
|
||||
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
|
||||
"properties": {
|
||||
"capture_monitor": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "**Mirror a physical monitor instead of creating a virtual display**: the connector name\n(`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.\n\nOrthogonal to `preset`/lifecycle (like `game_session`): a preset change never clears it, and\n`#[serde(default)]` leaves existing `display-settings.json` files untouched. It is a\n**host-wide** setting, not per-client — the host-pinned decision of record in\n`design/per-monitor-portal-capture.md` §5.3. `PUNKTFUNK_CAPTURE_MONITOR` overrides it (see\n[`capture_monitor`]), so an appliance can pin in `host.env` without the console fighting it."
|
||||
},
|
||||
"ddc_power_off": {
|
||||
"type": "boolean",
|
||||
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
|
||||
@@ -5720,6 +5819,43 @@
|
||||
"reject"
|
||||
]
|
||||
},
|
||||
"MonitorsResponse": {
|
||||
"type": "object",
|
||||
"description": "The host's physical monitors + which one capture is pinned to.",
|
||||
"required": [
|
||||
"monitors"
|
||||
],
|
||||
"properties": {
|
||||
"compositor": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Compositor backend the enumeration came from (`kwin`, `mutter`, …), when one was resolved."
|
||||
},
|
||||
"error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Why the list is empty, when enumeration failed (compositor unreachable, unsupported\nplatform). `None` with an empty list means \"asked, and there are none\"."
|
||||
},
|
||||
"monitors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ApiMonitorInfo"
|
||||
},
|
||||
"description": "The heads, ordered left-to-right by desktop position."
|
||||
},
|
||||
"pinned": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing,\nso the console can show \"pinned to DP-2, which this host doesn't have\"."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NativeClient": {
|
||||
"type": "object",
|
||||
"description": "A paired native (punktfunk/1) client.",
|
||||
@@ -6633,7 +6769,7 @@
|
||||
"mbps": {
|
||||
"type": "number",
|
||||
"format": "float",
|
||||
"description": "Transmit goodput (Mb/s)."
|
||||
"description": "Attempted sealed wire bytes/s (Mb/s): full UDP payloads at seal time — video AU bytes\nplus shard framing (header + AEAD) plus FEC parity, and for PyroWave's datagram-aligned\nmode the zero-padded window tails. NOT goodput, and NOT reduced by socket send drops."
|
||||
},
|
||||
"packets_dropped": {
|
||||
"type": "integer",
|
||||
|
||||
@@ -13,8 +13,17 @@
|
||||
// console behind the couch UI looks like a crash.
|
||||
#![cfg_attr(windows, windows_subsystem = "windows")]
|
||||
|
||||
// The shared client log sink (std-only): a couch launch has no console, so the session's
|
||||
// stderr would otherwise evaporate — same reasoning as the shell's tee. This shim only
|
||||
// initializes + forwards; the module's subscriber-side surface stays unused here.
|
||||
#[cfg(windows)]
|
||||
#[path = "../logfile.rs"]
|
||||
#[allow(dead_code)]
|
||||
mod logfile;
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
logfile::init();
|
||||
// The session binary ships beside us in the package; fall back to PATH for a dev run.
|
||||
let session = std::env::current_exe()
|
||||
.ok()
|
||||
@@ -27,7 +36,14 @@ fn main() {
|
||||
if !std::env::args().any(|a| a == "--windowed") {
|
||||
cmd.arg("--fullscreen");
|
||||
}
|
||||
match cmd.status() {
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
let run = cmd.spawn().and_then(|mut child| {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
logfile::forward_child_stderr(stderr);
|
||||
}
|
||||
child.wait()
|
||||
});
|
||||
match run {
|
||||
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
|
||||
Err(_) => std::process::exit(1),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Persistent client log file: `%LOCALAPPDATA%\punktfunk\logs\client.log`.
|
||||
//!
|
||||
//! The shell is a `windows_subsystem` binary and spawns `punktfunk-session` with
|
||||
//! `CREATE_NO_WINDOW` — a normal GUI/MSIX launch has NO console, so before this module every
|
||||
//! log line (the shell's and, worse, the session's whole receive/decode/present forensic
|
||||
//! trail) evaporated exactly when a user hit a problem worth reporting. The 2026-07 PyroWave
|
||||
//! latency-sawtooth field report had to be triaged from host logs alone because the client
|
||||
//! side had nowhere to land.
|
||||
//!
|
||||
//! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over
|
||||
//! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is
|
||||
//! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
/// Rotate at the next start once the file exceeds this (the host's cap).
|
||||
const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
|
||||
|
||||
fn log_dir() -> Option<PathBuf> {
|
||||
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs"))
|
||||
}
|
||||
|
||||
/// The log file's path, for the "logs land here" startup line (and any future UI affordance).
|
||||
pub(crate) fn path() -> Option<PathBuf> {
|
||||
Some(log_dir()?.join("client.log"))
|
||||
}
|
||||
|
||||
/// Open (rotating first) and cache the sink. Called once at startup, before the tracing
|
||||
/// subscriber installs; every later [`tee`] shares the handle.
|
||||
pub(crate) fn init() {
|
||||
SINK.get_or_init(|| {
|
||||
let dir = log_dir()?;
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let path = dir.join("client.log");
|
||||
if std::fs::metadata(&path).is_ok_and(|m| m.len() > ROTATE_BYTES) {
|
||||
let old = dir.join("client.log.old");
|
||||
// Windows `rename` refuses an existing destination — drop the old generation first.
|
||||
let _ = std::fs::remove_file(&old);
|
||||
let _ = std::fs::rename(&path, &old);
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.ok()?;
|
||||
Some(Arc::new(Mutex::new(file)))
|
||||
});
|
||||
}
|
||||
|
||||
/// A writer that duplicates onto stderr (dev runs from a terminal keep their interleaved
|
||||
/// output) and the log file (GUI runs finally keep anything at all). The tracing subscriber's
|
||||
/// `with_writer` factory and the session-stderr forwarder both use it.
|
||||
pub(crate) struct Tee;
|
||||
|
||||
/// `with_writer` factory (`fn() -> Tee` satisfies `MakeWriter`).
|
||||
pub(crate) fn tee() -> Tee {
|
||||
Tee
|
||||
}
|
||||
|
||||
impl Write for Tee {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let _ = io::stderr().write_all(buf);
|
||||
if let Some(Some(f)) = SINK.get() {
|
||||
let _ = f.lock().unwrap().write_all(buf);
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
let _ = io::stderr().flush();
|
||||
if let Some(Some(f)) = SINK.get() {
|
||||
let _ = f.lock().unwrap().flush();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward a spawned child's stderr into the [`Tee`], line-buffered so its lines never
|
||||
/// interleave mid-line with the shell's own. Returns immediately; the thread dies with the
|
||||
/// pipe (child exit).
|
||||
pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("punktfunk-session-log".into())
|
||||
.spawn(move || {
|
||||
let mut reader = io::BufReader::new(stderr);
|
||||
let mut line = String::new();
|
||||
let mut tee = Tee;
|
||||
while matches!(reader.read_line(&mut line), Ok(n) if n > 0) {
|
||||
let _ = tee.write_all(line.as_bytes());
|
||||
line.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,8 @@ mod discovery;
|
||||
#[cfg(windows)]
|
||||
mod gpu;
|
||||
#[cfg(windows)]
|
||||
mod logfile;
|
||||
#[cfg(windows)]
|
||||
mod probe;
|
||||
#[cfg(windows)]
|
||||
mod shell_window;
|
||||
@@ -49,11 +51,20 @@ fn main() {
|
||||
}
|
||||
set_app_user_model_id();
|
||||
|
||||
// Everything logs to stderr AND `%LOCALAPPDATA%\punktfunk\logs\client.log` (see [`logfile`]):
|
||||
// a GUI/MSIX launch has no console, so without the file the client side of any field report
|
||||
// simply doesn't exist. ANSI off — the file is what users send, keep it grep-clean.
|
||||
logfile::init();
|
||||
tracing_subscriber::fmt()
|
||||
.with_ansi(false)
|
||||
.with_writer(logfile::tee)
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
if let Some(p) = logfile::path() {
|
||||
tracing::info!(path = %p.display(), "client log file (rotated at 10 MB, one .old kept)");
|
||||
}
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let flag = |name: &str| args.iter().any(|a| a == name);
|
||||
@@ -88,7 +99,16 @@ fn main() {
|
||||
if !flag("--windowed") {
|
||||
cmd.arg("--fullscreen");
|
||||
}
|
||||
match cmd.status() {
|
||||
// Spawn (not `status()`) so the session's stderr rides the log tee — a couch launch
|
||||
// (Start-menu tile, Steam shortcut) has no console to inherit either.
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
let run = cmd.spawn().and_then(|mut child| {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
logfile::forward_child_stderr(stderr);
|
||||
}
|
||||
child.wait()
|
||||
});
|
||||
match run {
|
||||
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
|
||||
Err(e) => {
|
||||
eprintln!("could not start the console UI: {e}");
|
||||
|
||||
@@ -169,13 +169,19 @@ fn spawn_with(
|
||||
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit()) // session logs interleave with the shell's (dev runs)
|
||||
// Piped through the log tee: dev-terminal runs keep the interleaved stderr they always
|
||||
// had, and GUI runs — which have no console — finally keep the session's whole
|
||||
// receive/decode/present log in the client log file.
|
||||
.stderr(Stdio::piped())
|
||||
.creation_flags(CREATE_NO_WINDOW);
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
|
||||
tracing::info!(host = %host_label, "session binary spawned");
|
||||
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
crate::logfile::forward_child_stderr(stderr);
|
||||
}
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
// Park the child where the kill handle (and the reader, for the final reap) reach it.
|
||||
*slot.0.lock().unwrap() = Some(child);
|
||||
|
||||
@@ -10,13 +10,18 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::os::fd::OwnedFd;
|
||||
|
||||
/// Whether any monitor of the live GNOME session is currently in BT.2100 (HDR) colour mode — the
|
||||
/// Whether the monitor this host would mirror is currently in BT.2100 (HDR) colour mode — the
|
||||
/// precondition for Mutter's monitor screencast advertising the 10-bit PQ formats (GNOME 50+;
|
||||
/// Mutter only appends the HDR formats while the mirrored monitor's colour state is BT.2020+PQ).
|
||||
/// Queried over the session bus: `DisplayConfig.GetCurrentState`, monitor property
|
||||
/// `"color-mode" == 1` (`META_COLOR_MODE_BT2100`). `false` on any error — not GNOME, a pre-48
|
||||
/// Mutter without colour modes, no monitors — so callers fall back to the honest SDR offer.
|
||||
/// Blocking (one D-Bus round-trip on a fresh connection); call from control-plane threads only.
|
||||
///
|
||||
/// **Scoped to `PUNKTFUNK_CAPTURE_MONITOR` when it is set** (`design/per-monitor-portal-capture.md`
|
||||
/// §7.4). Without a pin this asks "is ANY monitor in HDR mode", which was a fair heuristic while the
|
||||
/// capture path took whatever monitor it was handed — but once the operator names the head, an
|
||||
/// HDR-capable *neighbour* must not talk this host into offering PQ formats for an SDR panel.
|
||||
pub fn gnome_hdr_monitor_active() -> bool {
|
||||
use ashpd::zbus;
|
||||
// GetCurrentState reply: (serial, monitors, logical_monitors, properties); each monitor is
|
||||
@@ -74,12 +79,21 @@ pub fn gnome_hdr_monitor_active() -> bool {
|
||||
.body()
|
||||
.deserialize()
|
||||
.context("parse GetCurrentState")?;
|
||||
Ok(monitors.iter().any(|(_spec, _modes, props)| {
|
||||
props
|
||||
.get("color-mode")
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.is_some_and(|mode| mode == 1) // META_COLOR_MODE_BT2100
|
||||
}))
|
||||
// `spec.0` is the connector; "color-mode" 1 is META_COLOR_MODE_BT2100.
|
||||
let heads: Vec<(&str, bool)> = monitors
|
||||
.iter()
|
||||
.map(|(spec, _modes, props)| {
|
||||
let hdr = props
|
||||
.get("color-mode")
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.is_some_and(|mode| mode == 1);
|
||||
(spec.0.as_str(), hdr)
|
||||
})
|
||||
.collect();
|
||||
Ok(hdr_offer_for(
|
||||
&heads,
|
||||
pf_host_config::config().capture_monitor.as_deref(),
|
||||
))
|
||||
})
|
||||
};
|
||||
match probe() {
|
||||
@@ -91,6 +105,24 @@ pub fn gnome_hdr_monitor_active() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Should this host offer the HDR (10-bit PQ) formats, given each head as `(connector, is_bt2100)`
|
||||
/// and the `PUNKTFUNK_CAPTURE_MONITOR` pin?
|
||||
///
|
||||
/// Pinned: only that head's colour mode counts — an HDR-capable neighbour must not talk the host
|
||||
/// into offering PQ for the SDR panel it is actually streaming. A pin naming no live head reports
|
||||
/// SDR rather than falling back to "any": the session is about to fail on that same missing
|
||||
/// monitor, and an over-claimed HDR offer would be a second, quieter wrong answer.
|
||||
/// Unpinned: the pre-existing "any monitor is in HDR mode" heuristic, unchanged.
|
||||
fn hdr_offer_for(heads: &[(&str, bool)], pinned: Option<&str>) -> bool {
|
||||
match pinned {
|
||||
Some(want) => heads
|
||||
.iter()
|
||||
.find(|(connector, _)| connector.eq_ignore_ascii_case(want))
|
||||
.is_some_and(|(_, hdr)| *hdr),
|
||||
None => heads.iter().any(|(_, hdr)| *hdr),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`).
|
||||
/// With `want_metadata` the ladder prefers **cursor-as-metadata**: the compositor keeps its cheap
|
||||
/// hardware cursor plane and ships the pointer as PipeWire `SPA_META_Cursor` metadata (position +
|
||||
@@ -355,3 +387,33 @@ pub(super) fn portal_thread_remote_desktop(
|
||||
// See `portal_thread`: drop the runtime before the caller's completion signal.
|
||||
drop(rt);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hdr_offer_tests {
|
||||
use super::hdr_offer_for;
|
||||
|
||||
#[test]
|
||||
fn unpinned_keeps_the_any_monitor_heuristic() {
|
||||
assert!(hdr_offer_for(&[("DP-1", false), ("HDMI-A-1", true)], None));
|
||||
assert!(!hdr_offer_for(&[("DP-1", false)], None));
|
||||
}
|
||||
|
||||
/// The regression this exists to prevent: an HDR TV on HDMI while the pinned head is an SDR
|
||||
/// desk monitor. Before scoping, the host offered PQ formats for a panel that can't show them.
|
||||
#[test]
|
||||
fn a_pin_ignores_an_hdr_neighbour() {
|
||||
let heads = [("DP-1", false), ("HDMI-A-1", true)];
|
||||
assert!(!hdr_offer_for(&heads, Some("DP-1")));
|
||||
assert!(hdr_offer_for(&heads, Some("HDMI-A-1")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pin_matches_case_insensitively_like_the_resolver() {
|
||||
assert!(hdr_offer_for(&[("HDMI-A-1", true)], Some("hdmi-a-1")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pin_naming_no_live_head_reports_sdr() {
|
||||
assert!(!hdr_offer_for(&[("DP-1", true)], Some("DP-9")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -732,6 +732,18 @@ impl IddPushCapturer {
|
||||
return; // no new sample since last consume
|
||||
}
|
||||
self.desc_seq = seq;
|
||||
// A topology reassert is in flight (the exclusive watchdog announced it): every sample in
|
||||
// this window is potentially the TRANSIENT eviction state, and acting on one recreates
|
||||
// the ring at a mode the reassert's recovery chain (`recreate_ring_in_place`, keyed off
|
||||
// the reassert generation) is about to undo — the field hdr=true→false→true double
|
||||
// recreate. Consume the sample, disarm the debounce, act on nothing; a REAL change that
|
||||
// races the window survives it (the descriptor still differs once the hold clears, and
|
||||
// the poller re-samples in ~250 ms). This also keeps the negotiated-depth pin-back below
|
||||
// from issuing a CCD write mid-eviction, where it would fight the reassert itself.
|
||||
if pf_win_display::topology_churn::held() {
|
||||
self.pending_desc = None;
|
||||
return;
|
||||
}
|
||||
// Two cases re-assert the NEGOTIATED depth instead of following a mid-session "Use HDR"
|
||||
// flip — flip the display back and treat the descriptor as the negotiated state (so the ring
|
||||
// is never recreated at the wrong format):
|
||||
|
||||
@@ -115,7 +115,7 @@ impl StallWatch {
|
||||
delivered no frame for the gap; the present path stalled below capture"
|
||||
);
|
||||
if let Some(period) = stall.metronomic {
|
||||
let suspects = pf_win_display::display_events::connected_inactive_externals();
|
||||
let suspects = pf_win_display::display_events::connected_inactive_physicals();
|
||||
let suspects = if suspects.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
@@ -151,10 +151,13 @@ impl StallWatch {
|
||||
display-poller software (the SteelSeries-GG/SignalRGB class — \
|
||||
correlate 'slow display-descriptor poll' lines), or the DWM present \
|
||||
clock (try a different refresh rate). If connected_inactive lists a \
|
||||
display, its standby probing is the prime suspect: unplug it at the \
|
||||
GPU, disable its OSD auto input scan (TVs: instant-on/quick-start + \
|
||||
CEC off), use an HPD-holding adapter/dummy, or keep it active while \
|
||||
streaming"
|
||||
display, its standby servicing is the prime suspect. For a LAPTOP \
|
||||
PANEL (the exclusive isolate deactivated it — the dark-but-connected \
|
||||
head is itself the disturbance on hybrid laptops): keep it active \
|
||||
with `topology: primary`, or try the `pnp_disable_monitors` axis. \
|
||||
For an external display: unplug it at the GPU, disable its OSD auto \
|
||||
input scan (TVs: instant-on/quick-start + CEC off), use an \
|
||||
HPD-holding adapter/dummy, or keep it active while streaming"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +395,9 @@ pub struct PyroWaveEncoder {
|
||||
/// packet to it, so each wire shard carries whole self-delimiting packets. `None` =
|
||||
/// one packet per AU (the dense MVP shape).
|
||||
wire_chunk: Option<usize>,
|
||||
/// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the
|
||||
/// WIRE, not just the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
|
||||
wire_budget: crate::pyrowave_wire::WireBudget,
|
||||
bitstream: Vec<u8>,
|
||||
pending: VecDeque<EncodedFrame>,
|
||||
frame_count: u64,
|
||||
@@ -653,6 +656,7 @@ impl PyroWaveEncoder {
|
||||
chroma444,
|
||||
frame_budget: budget_for(bitrate, fps),
|
||||
wire_chunk: None,
|
||||
wire_budget: crate::pyrowave_wire::WireBudget::new(),
|
||||
bitstream: Vec::new(),
|
||||
pending: VecDeque::new(),
|
||||
frame_count: 0,
|
||||
@@ -1120,6 +1124,16 @@ impl PyroWaveEncoder {
|
||||
Ok(self.cpu_img.unwrap().2)
|
||||
}
|
||||
|
||||
/// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the
|
||||
/// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is
|
||||
/// a promise about the wire, not the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
|
||||
fn rate_budget(&self) -> usize {
|
||||
match self.wire_chunk {
|
||||
Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024),
|
||||
None => self.frame_budget,
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame, synchronously: ingest → CSC → pyrowave encode (recorded into our command
|
||||
/// buffer) → submit + fence wait (sub-ms) → packetize into an `EncodedFrame`.
|
||||
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
|
||||
@@ -1175,6 +1189,8 @@ impl PyroWaveEncoder {
|
||||
// paths propagate untouched and the recovery (`reset()`/`Drop`) `device_wait_idle()`s
|
||||
// before anything touches `cmd`; a buffer that completed its one-time submit is INVALID,
|
||||
// which the next `begin` may implicitly reset.
|
||||
// Resolved before the closure (which borrows `self` mutably for the recording calls).
|
||||
let rate_budget = self.rate_budget();
|
||||
let record_and_submit = (|| -> Result<()> {
|
||||
dev.begin_command_buffer(
|
||||
self.cmd,
|
||||
@@ -1396,7 +1412,7 @@ impl PyroWaveEncoder {
|
||||
],
|
||||
};
|
||||
let rc = pw::pyrowave_rate_control {
|
||||
maximum_bitstream_size: self.frame_budget,
|
||||
maximum_bitstream_size: rate_budget,
|
||||
};
|
||||
pw::pyrowave_device_set_command_buffer(
|
||||
self.pw_dev,
|
||||
@@ -1476,6 +1492,10 @@ impl PyroWaveEncoder {
|
||||
// single packet, or the datagram-aligned windowed AU (§4.4).
|
||||
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
|
||||
let au = crate::pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
|
||||
if self.wire_chunk.is_some() {
|
||||
let raw: usize = pkts.iter().map(|&(_, s)| s).sum();
|
||||
self.wire_budget.observe(raw, au.len());
|
||||
}
|
||||
self.frame_count += 1;
|
||||
self.pending.push_back(EncodedFrame {
|
||||
data: au,
|
||||
|
||||
@@ -81,6 +81,61 @@ pub(crate) fn block_count_32x32(width: u32, height: u32, chroma444: bool) -> u32
|
||||
count
|
||||
}
|
||||
|
||||
/// Wire-aware deflation of the per-frame rate budget for the datagram-aligned mode.
|
||||
///
|
||||
/// [`build_au`]'s windowing inflates the codec bitstream on its way to the wire: greedy packing
|
||||
/// of pyrowave's few-hundred-byte atomic block packets into `chunk`-sized windows leaves the
|
||||
/// tail of most windows zero-padded, plus the 4-byte prefixes and FRAG-chain tails. At
|
||||
/// 1440p/~850 KiB frames that is ×1.2–1.3 — the 2026-07 field report's "Automatic" 407 Mb/s
|
||||
/// pin put 550 Mb/s on a 1 GbE link. The pin is a promise about the LINK, so the codec budget
|
||||
/// must absorb the framing: this tracker measures the real AU/bitstream ratio per frame and
|
||||
/// deflates the budget handed to pyrowave's rate control by its EMA. Sealed-datagram framing
|
||||
/// (packet header + AEAD tag) and FEC parity are deliberately NOT compensated — H.26x sessions
|
||||
/// carry those on top of the configured bitrate too, and the pin must mean the same thing for
|
||||
/// every codec.
|
||||
pub(crate) struct WireBudget {
|
||||
/// EMA of `built AU bytes / packetized bitstream bytes`, ×1024 fixed point.
|
||||
scale_x1024: u32,
|
||||
}
|
||||
|
||||
impl WireBudget {
|
||||
/// Startup prior (×1024 ≈ 1.25 — the 1440p field measurement's midpoint); the EMA
|
||||
/// converges onto the session's real ratio within ~a second of frames.
|
||||
const PRIOR_X1024: u32 = 1280;
|
||||
/// EMA weight 1/8: content-driven per-frame wobble smooths out; a mode/bitrate change
|
||||
/// re-converges in ~16 frames.
|
||||
const EMA_SHIFT: u32 = 3;
|
||||
/// Sanity clamp on the applied scale: never inflate the budget (×1.0 floor), never
|
||||
/// deflate below half (×2.0 cap — tiny explicit bitrates window very coarsely).
|
||||
const MIN_X1024: u32 = 1024;
|
||||
const MAX_X1024: u32 = 2048;
|
||||
|
||||
pub(crate) fn new() -> WireBudget {
|
||||
WireBudget {
|
||||
scale_x1024: Self::PRIOR_X1024,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one frame's measured inflation (`bitstream_len` = the packetized codec bytes the
|
||||
/// rate controller budgeted; `au_len` = the windowed AU that actually reaches the wire).
|
||||
pub(crate) fn observe(&mut self, bitstream_len: usize, au_len: usize) {
|
||||
if bitstream_len == 0 {
|
||||
return;
|
||||
}
|
||||
let sample = ((au_len as u64 * 1024) / bitstream_len as u64)
|
||||
.clamp(Self::MIN_X1024 as u64, Self::MAX_X1024 as u64) as u32;
|
||||
let ema = self.scale_x1024 as i64;
|
||||
self.scale_x1024 = (ema + ((sample as i64 - ema) >> Self::EMA_SHIFT)) as u32;
|
||||
}
|
||||
|
||||
/// The rate-control budget that makes the WIRE hit `budget` bytes/frame under the
|
||||
/// currently-measured inflation.
|
||||
pub(crate) fn deflate(&self, budget: usize) -> usize {
|
||||
let scale = self.scale_x1024.clamp(Self::MIN_X1024, Self::MAX_X1024) as u64;
|
||||
((budget as u64 * 1024) / scale) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame pyrowave's `packets` (each an `(offset, size)` into `bitstream`) into the wire AU.
|
||||
/// `wire_chunk = None` copies the single dense packet; `Some(chunk)` produces the windowed
|
||||
/// datagram-aligned AU (a whole number of `chunk`-sized windows).
|
||||
@@ -259,6 +314,37 @@ mod tests {
|
||||
assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32);
|
||||
}
|
||||
|
||||
/// The wire-budget tracker: converges its EMA onto the measured AU/bitstream inflation,
|
||||
/// deflates the budget by exactly that ratio, and clamps runaway samples.
|
||||
#[test]
|
||||
fn wire_budget_converges_and_deflates() {
|
||||
let mut wb = WireBudget::new();
|
||||
// Prior ≈ ×1.25 (1280/1024): the first deflation is already conservative.
|
||||
assert_eq!(wb.deflate(1_024_000), 819_200);
|
||||
// Feed a steady ×1.30 inflation; the EMA must converge onto it.
|
||||
for _ in 0..64 {
|
||||
wb.observe(1000, 1300);
|
||||
}
|
||||
let b = wb.deflate(1_024_000);
|
||||
let expect = 1_024_000_u64 * 1000 / 1300;
|
||||
assert!(
|
||||
(b as i64 - expect as i64).unsigned_abs() < 8_000,
|
||||
"budget {b} should approach {expect}"
|
||||
);
|
||||
// A dense-ish run (×1.0) walks it back down to no deflation.
|
||||
for _ in 0..64 {
|
||||
wb.observe(1000, 1000);
|
||||
}
|
||||
assert_eq!(wb.deflate(1_024_000), 1_024_000);
|
||||
// Garbage samples are clamped: an absurd ratio can at most halve the budget…
|
||||
for _ in 0..256 {
|
||||
wb.observe(10, 1000);
|
||||
}
|
||||
assert!(wb.deflate(1_024_000) >= 512_000);
|
||||
// …and a zero-length observation is ignored, never a division by zero.
|
||||
wb.observe(0, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_color_bits_sets_range_and_hdr_bits() {
|
||||
let mut bs = vec![0u8; 16];
|
||||
|
||||
@@ -159,6 +159,9 @@ pub struct PyroWaveEncoder {
|
||||
frame_budget: usize,
|
||||
/// Datagram-aligned mode (plan §4.4): packetize at this boundary. `None` = one dense packet/AU.
|
||||
wire_chunk: Option<usize>,
|
||||
/// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the
|
||||
/// WIRE, not just the raw bitstream (see [`pyrowave_wire::WireBudget`]).
|
||||
wire_budget: pyrowave_wire::WireBudget,
|
||||
bitstream: Vec<u8>,
|
||||
pending: VecDeque<EncodedFrame>,
|
||||
}
|
||||
@@ -288,6 +291,7 @@ impl PyroWaveEncoder {
|
||||
hdr16,
|
||||
frame_budget,
|
||||
wire_chunk: None,
|
||||
wire_budget: pyrowave_wire::WireBudget::new(),
|
||||
bitstream: Vec::new(),
|
||||
pending: VecDeque::new(),
|
||||
})
|
||||
@@ -441,6 +445,16 @@ impl PyroWaveEncoder {
|
||||
///
|
||||
/// # Safety
|
||||
/// Runs on the single encode thread; all pyrowave calls take handles this struct owns.
|
||||
/// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the
|
||||
/// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is
|
||||
/// a promise about the wire, not the raw bitstream (see [`pyrowave_wire::WireBudget`]).
|
||||
fn rate_budget(&self) -> usize {
|
||||
match self.wire_chunk {
|
||||
Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024),
|
||||
None => self.frame_budget,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
|
||||
// A failed `reset()` leaves the encoder destroyed and null — fail cleanly rather than
|
||||
// handing null to pyrowave (see the Linux twin).
|
||||
@@ -616,7 +630,7 @@ impl PyroWaveEncoder {
|
||||
sync: std::mem::zeroed(),
|
||||
};
|
||||
let rc = pw::pyrowave_rate_control {
|
||||
maximum_bitstream_size: self.frame_budget,
|
||||
maximum_bitstream_size: self.rate_budget(),
|
||||
};
|
||||
pw_check(
|
||||
pw::pyrowave_encoder_encode_gpu_synchronous(
|
||||
@@ -664,6 +678,10 @@ impl PyroWaveEncoder {
|
||||
}
|
||||
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
|
||||
let au = pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
|
||||
if self.wire_chunk.is_some() {
|
||||
let raw: usize = pkts.iter().map(|&(_, s)| s).sum();
|
||||
self.wire_budget.observe(raw, au.len());
|
||||
}
|
||||
self.pending.push_back(EncodedFrame {
|
||||
data: au,
|
||||
pts_ns: frame.pts_ns,
|
||||
|
||||
@@ -96,6 +96,14 @@ pub struct HostConfig {
|
||||
pub perf: bool,
|
||||
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
|
||||
pub video_source: Option<String>,
|
||||
/// `PUNKTFUNK_CAPTURE_MONITOR` — pin capture at a NAMED physical monitor (`DP-1`, `HDMI-A-2`),
|
||||
/// instead of creating a virtual display or taking whichever head the portal hands back. The
|
||||
/// point of the knob is an unattended host: a background `systemd --user` service has nobody to
|
||||
/// answer a chooser dialog, so the monitor has to be config, not a prompt. A name that matches
|
||||
/// no head is a hard error at session open (never a silent fall-back to a different screen —
|
||||
/// showing the wrong monitor is worse than showing none). Linux-only today; see
|
||||
/// `design/per-monitor-portal-capture.md`.
|
||||
pub capture_monitor: Option<String>,
|
||||
/// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected
|
||||
/// session — this one is a constant operator knob; `apply_session_env` never writes it.
|
||||
pub compositor: Option<String>,
|
||||
@@ -170,6 +178,11 @@ impl HostConfig {
|
||||
chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true),
|
||||
perf: flag("PUNKTFUNK_PERF"),
|
||||
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
|
||||
// Trimmed + emptied-to-None: `PUNKTFUNK_CAPTURE_MONITOR=` in a host.env means "not
|
||||
// set", not "match the monitor named empty string".
|
||||
capture_monitor: val("PUNKTFUNK_CAPTURE_MONITOR")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||
gamepad: val("PUNKTFUNK_GAMEPAD"),
|
||||
vdisplay: val("PUNKTFUNK_VDISPLAY"),
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
//! key events) is enough.
|
||||
|
||||
use super::{gs_button_to_evdev, vk_to_evdev, InputInjector};
|
||||
use crate::AbsoluteAnchor;
|
||||
use anyhow::{anyhow, Result};
|
||||
use ashpd::desktop::{
|
||||
remote_desktop::{
|
||||
@@ -374,25 +375,98 @@ async fn connect_socket_file(file: &std::path::Path) -> Result<(UnixStream, Opti
|
||||
}
|
||||
|
||||
/// One EI device and its emulation state.
|
||||
/// Pick the region to map absolute coordinates into: the one whose logical size matches the
|
||||
/// streamed mode (the session's virtual output). The device advertises one region per logical
|
||||
/// monitor, and blindly taking `first()` next to a physical monitor put the pointer — and every
|
||||
/// Pick the region to map absolute coordinates into. The device advertises one region per logical
|
||||
/// monitor and blindly taking `first()` next to a physical monitor put the pointer — and every
|
||||
/// click — on whichever output the compositor happened to announce first (on-glass: GNOME with a
|
||||
/// dummy HDMI beside the virtual primary; the seat cursor never entered the streamed monitor, so
|
||||
/// neither embedded nor metadata cursor capture could see it). Size is the only key available
|
||||
/// today: regions carry no output name, and matching the screencast stream's `mapping_id` needs
|
||||
/// the stream id plumbed across crates (follow-up). Two same-sized monitors stay ambiguous.
|
||||
fn region_for_mode(
|
||||
regions: &[reis::event::Region],
|
||||
/// neither embedded nor metadata cursor capture could see it).
|
||||
///
|
||||
/// The ladder, most identifying first:
|
||||
///
|
||||
/// 1. **`mapping_id`** from the session's [`AbsoluteAnchor`] — the protocol's own key for
|
||||
/// correlating a region with a video stream.
|
||||
/// 2. **origin** from the anchor — two outputs can share a size, never a top-left. This is what
|
||||
/// makes a *mirrored physical monitor* land correctly (`design/per-monitor-portal-capture.md`
|
||||
/// §7.2): its region is not the client's size, so the size rung can't find it.
|
||||
/// 3. **size** — the streamed mode. Correct for a client-sized virtual output, ambiguous the
|
||||
/// moment two heads share a mode, which is exactly why the rungs above exist.
|
||||
/// 4. `first()`.
|
||||
///
|
||||
/// An anchor that matches nothing falls through rather than failing: the region set is the truth
|
||||
/// and the anchor is our belief about it. The caller logs that miss ([`anchor_missed`]).
|
||||
fn region_for_mode<'a>(
|
||||
regions: &'a [reis::event::Region],
|
||||
w: f32,
|
||||
h: f32,
|
||||
) -> Option<&reis::event::Region> {
|
||||
anchor: Option<&AbsoluteAnchor>,
|
||||
) -> Option<&'a reis::event::Region> {
|
||||
if let Some(a) = anchor {
|
||||
if let Some(id) = a.mapping_id.as_deref() {
|
||||
if let Some(r) = regions.iter().find(|r| r.mapping_id.as_deref() == Some(id)) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
if let Some((x, y)) = a.origin {
|
||||
// EI region offsets are unsigned; a compositor places every output at a non-negative
|
||||
// origin in its own global space, so a negative anchor simply matches nothing.
|
||||
if x >= 0 && y >= 0 {
|
||||
if let Some(r) = regions.iter().find(|r| r.x == x as u32 && r.y == y as u32) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
regions
|
||||
.iter()
|
||||
.find(|r| r.width as f32 == w && r.height as f32 == h)
|
||||
.or_else(|| regions.first())
|
||||
}
|
||||
|
||||
/// Report which region absolute coordinates actually landed in, once per distinct answer.
|
||||
///
|
||||
/// The ladder above is only *observable* through where the pointer ends up, which on a two-head box
|
||||
/// is precisely the thing that is hard to see and easy to get wrong — the anchor exists because it
|
||||
/// already resolved wrong on-glass once, silently. `warn_anchor_miss` covers the anchor naming
|
||||
/// nothing; this covers the other half, an anchor that matched *something*, by saying which. Once
|
||||
/// per distinct region so a live session logs one line, not one per motion event.
|
||||
fn note_abs_region(region: &reis::event::Region, anchor: Option<&AbsoluteAnchor>) {
|
||||
static LAST: std::sync::Mutex<Option<(u32, u32, u32, u32)>> = std::sync::Mutex::new(None);
|
||||
let key = (region.x, region.y, region.width, region.height);
|
||||
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if *last == Some(key) {
|
||||
return;
|
||||
}
|
||||
*last = Some(key);
|
||||
tracing::info!(
|
||||
region = %format!("{}x{}+{}+{}", region.width, region.height, region.x, region.y),
|
||||
mapping_id = ?region.mapping_id,
|
||||
anchor_origin = ?anchor.and_then(|a| a.origin),
|
||||
anchor_mapping_id = ?anchor.and_then(|a| a.mapping_id.clone()),
|
||||
"libei: absolute input maps into this output"
|
||||
);
|
||||
}
|
||||
|
||||
/// Did an anchor name an output this region set doesn't have? Drives the one-shot warning — a
|
||||
/// silently mis-mapped pointer is the failure this whole ladder exists to prevent, so when the
|
||||
/// anchor can't be honored that has to be visible in the log rather than inferred from "clicks land
|
||||
/// on the wrong screen".
|
||||
fn anchor_missed(regions: &[reis::event::Region], anchor: Option<&AbsoluteAnchor>) -> bool {
|
||||
let Some(a) = anchor else {
|
||||
return false;
|
||||
};
|
||||
if let Some(id) = a.mapping_id.as_deref() {
|
||||
if regions.iter().any(|r| r.mapping_id.as_deref() == Some(id)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some((x, y)) = a.origin {
|
||||
if x >= 0 && y >= 0 && regions.iter().any(|r| r.x == x as u32 && r.y == y as u32) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
struct DeviceSlot {
|
||||
device: reis::event::Device,
|
||||
/// The device is resumed (allowed to emit). Devices arrive paused and may pause again.
|
||||
@@ -430,6 +504,30 @@ struct EiState {
|
||||
output_hint: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// The anchor whose miss we last warned about, so a persistently unmatchable anchor logs once per
|
||||
/// *change* instead of once per pointer sample (absolute motion arrives at client frame rate).
|
||||
static LAST_WARNED_ANCHOR: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Warn — once per distinct anchor — that the session's anchor names an output this EIS doesn't
|
||||
/// advertise, so absolute coordinates fell back to size matching. See [`region_for_mode`].
|
||||
fn warn_anchor_miss(anchor: &AbsoluteAnchor, regions: &[reis::event::Region]) {
|
||||
let key = format!("{anchor:?}");
|
||||
let mut last = LAST_WARNED_ANCHOR.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.as_deref() == Some(key.as_str()) {
|
||||
return;
|
||||
}
|
||||
*last = Some(key);
|
||||
tracing::warn!(
|
||||
?anchor,
|
||||
regions = ?regions
|
||||
.iter()
|
||||
.map(|r| (r.x, r.y, r.width, r.height, r.mapping_id.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
"libei: the session's absolute-coordinate anchor matches no EIS region — falling back to \
|
||||
size matching, so the pointer may land on the wrong monitor"
|
||||
);
|
||||
}
|
||||
|
||||
/// Is this EIS region a plausible OUTPUT geometry — something to map normalized coordinates
|
||||
/// into? gamescope advertises a degenerate `(0,0,INT32_MAX,INT32_MAX)` "everything" region on
|
||||
/// its virtual input device, meaning "absolute coordinates are raw"; normalizing into it
|
||||
@@ -763,13 +861,23 @@ impl EiState {
|
||||
// raw client pixels as the last resort.
|
||||
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
|
||||
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
|
||||
let (x, y) = match region_for_mode(slot.regions(), w, h)
|
||||
let anchor = crate::absolute_anchor();
|
||||
if let Some(a) = anchor
|
||||
.as_ref()
|
||||
.filter(|a| anchor_missed(slot.regions(), Some(a)))
|
||||
{
|
||||
warn_anchor_miss(a, slot.regions());
|
||||
}
|
||||
let (x, y) = match region_for_mode(slot.regions(), w, h, anchor.as_ref())
|
||||
.filter(|r| sane_region(r))
|
||||
{
|
||||
Some(region) => (
|
||||
region.x as f32 + nx * region.width as f32,
|
||||
region.y as f32 + ny * region.height as f32,
|
||||
),
|
||||
Some(region) => {
|
||||
note_abs_region(region, anchor.as_ref());
|
||||
(
|
||||
region.x as f32 + nx * region.width as f32,
|
||||
region.y as f32 + ny * region.height as f32,
|
||||
)
|
||||
}
|
||||
// Degenerate/absent region: scale into the relay-file output hint
|
||||
// (correct even when the client streams at a different resolution
|
||||
// than the session runs); raw client pixels as the last resort.
|
||||
@@ -847,14 +955,20 @@ impl EiState {
|
||||
Some(t) if w > 0.0 && h > 0.0 => {
|
||||
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
|
||||
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
|
||||
// Same region-selection + degenerate fallback ladder as MouseMoveAbs.
|
||||
let (x, y) = match region_for_mode(slot.regions(), w, h)
|
||||
// Same region-selection + degenerate fallback ladder as MouseMoveAbs
|
||||
// (including the session anchor — touch must land on the same monitor the
|
||||
// pointer does, or a mirrored session's taps go to a different screen).
|
||||
let anchor = crate::absolute_anchor();
|
||||
let (x, y) = match region_for_mode(slot.regions(), w, h, anchor.as_ref())
|
||||
.filter(|r| sane_region(r))
|
||||
{
|
||||
Some(region) => (
|
||||
region.x as f32 + nx * region.width as f32,
|
||||
region.y as f32 + ny * region.height as f32,
|
||||
),
|
||||
Some(region) => {
|
||||
note_abs_region(region, anchor.as_ref());
|
||||
(
|
||||
region.x as f32 + nx * region.width as f32,
|
||||
region.y as f32 + ny * region.height as f32,
|
||||
)
|
||||
}
|
||||
None => match self.output_hint {
|
||||
Some((ow, oh)) => (nx * ow as f32, ny * oh as f32),
|
||||
None => (ev.x as f32, ev.y as f32),
|
||||
@@ -913,3 +1027,122 @@ impl EiState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn region(x: u32, y: u32, w: u32, h: u32, mapping_id: Option<&str>) -> reis::event::Region {
|
||||
reis::event::Region {
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
scale: 1.0,
|
||||
mapping_id: mapping_id.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// The case the anchor exists for: two heads at the SAME size, so the size rung is a coin
|
||||
/// flip. The origin picks the right one.
|
||||
#[test]
|
||||
fn the_origin_disambiguates_two_same_size_monitors() {
|
||||
let regions = [
|
||||
region(0, 0, 1920, 1080, None),
|
||||
region(1920, 0, 1920, 1080, None),
|
||||
];
|
||||
let anchor = AbsoluteAnchor {
|
||||
origin: Some((1920, 0)),
|
||||
mapping_id: None,
|
||||
};
|
||||
let picked = region_for_mode(®ions, 1920.0, 1080.0, Some(&anchor)).unwrap();
|
||||
assert_eq!((picked.x, picked.y), (1920, 0));
|
||||
// Without the anchor the same call takes the first same-sized region — the old behavior,
|
||||
// preserved deliberately for the client-sized virtual-output path.
|
||||
let picked = region_for_mode(®ions, 1920.0, 1080.0, None).unwrap();
|
||||
assert_eq!((picked.x, picked.y), (0, 0));
|
||||
}
|
||||
|
||||
/// `mapping_id` outranks the origin: it is the protocol's own stream↔region correlation, so a
|
||||
/// stale/rounded origin can't override it.
|
||||
#[test]
|
||||
fn mapping_id_outranks_the_origin() {
|
||||
let regions = [
|
||||
region(0, 0, 1920, 1080, Some("head-a")),
|
||||
region(1920, 0, 1920, 1080, Some("head-b")),
|
||||
];
|
||||
let anchor = AbsoluteAnchor {
|
||||
origin: Some((0, 0)),
|
||||
mapping_id: Some("head-b".into()),
|
||||
};
|
||||
let picked = region_for_mode(®ions, 1920.0, 1080.0, Some(&anchor)).unwrap();
|
||||
assert_eq!(picked.mapping_id.as_deref(), Some("head-b"));
|
||||
}
|
||||
|
||||
/// A mirrored monitor's region is NOT the client's streamed size, so the size rung would miss
|
||||
/// it entirely — the origin is what makes this land.
|
||||
#[test]
|
||||
fn the_anchor_finds_a_monitor_the_streamed_size_does_not_match() {
|
||||
let regions = [
|
||||
region(0, 0, 1920, 1080, None),
|
||||
region(1920, 0, 3840, 2160, None),
|
||||
];
|
||||
// Client streams 1280x720 of a 4K head parked to the right.
|
||||
let anchor = AbsoluteAnchor {
|
||||
origin: Some((1920, 0)),
|
||||
mapping_id: None,
|
||||
};
|
||||
let picked = region_for_mode(®ions, 1280.0, 720.0, Some(&anchor)).unwrap();
|
||||
assert_eq!((picked.width, picked.height), (3840, 2160));
|
||||
}
|
||||
|
||||
/// An anchor that names nothing present must not strand input: fall back down the ladder
|
||||
/// (size, then first) — and say so, which `anchor_missed` drives.
|
||||
#[test]
|
||||
fn an_unmatched_anchor_falls_back_and_is_reported() {
|
||||
let regions = [region(0, 0, 1920, 1080, None)];
|
||||
let anchor = AbsoluteAnchor {
|
||||
origin: Some((5000, 5000)),
|
||||
mapping_id: None,
|
||||
};
|
||||
assert!(anchor_missed(®ions, Some(&anchor)));
|
||||
let picked = region_for_mode(®ions, 1920.0, 1080.0, Some(&anchor)).unwrap();
|
||||
assert_eq!((picked.x, picked.y), (0, 0), "fell back to the size match");
|
||||
// A matched anchor is never reported as missed.
|
||||
let ok = AbsoluteAnchor {
|
||||
origin: Some((0, 0)),
|
||||
mapping_id: None,
|
||||
};
|
||||
assert!(!anchor_missed(®ions, Some(&ok)));
|
||||
assert!(!anchor_missed(®ions, None), "no anchor is not a miss");
|
||||
}
|
||||
|
||||
/// EI region offsets are unsigned; a negative anchor origin can only ever match nothing, and
|
||||
/// must not be cast into a huge u32 that accidentally matches something.
|
||||
#[test]
|
||||
fn a_negative_origin_matches_nothing_rather_than_wrapping() {
|
||||
let regions = [region(0, 0, 1920, 1080, None)];
|
||||
let anchor = AbsoluteAnchor {
|
||||
origin: Some((-1920, 0)),
|
||||
mapping_id: None,
|
||||
};
|
||||
assert!(anchor_missed(®ions, Some(&anchor)));
|
||||
let picked = region_for_mode(®ions, 1920.0, 1080.0, Some(&anchor)).unwrap();
|
||||
assert_eq!((picked.x, picked.y), (0, 0));
|
||||
}
|
||||
|
||||
/// An empty anchor is the same as no anchor — so a caller can build one unconditionally and
|
||||
/// let the setter drop it.
|
||||
#[test]
|
||||
fn an_empty_anchor_is_dropped_by_the_setter() {
|
||||
crate::set_absolute_anchor(Some(AbsoluteAnchor::default()));
|
||||
assert_eq!(crate::absolute_anchor(), None);
|
||||
crate::set_absolute_anchor(Some(AbsoluteAnchor {
|
||||
origin: Some((1920, 0)),
|
||||
mapping_id: None,
|
||||
}));
|
||||
assert_eq!(crate::absolute_anchor().unwrap().origin, Some((1920, 0)));
|
||||
crate::set_absolute_anchor(None);
|
||||
assert_eq!(crate::absolute_anchor(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,64 @@ pub fn open(_backend: Backend) -> Result<Box<dyn InputInjector>> {
|
||||
anyhow::bail!("no input-injection backend on this platform")
|
||||
}
|
||||
|
||||
/// Which output the session's **absolute** coordinates belong to, by identity rather than by size.
|
||||
///
|
||||
/// libei hands the injector one region per logical monitor and the region set carries no output
|
||||
/// name, so the backend has to decide which one a normalized client position maps into. Matching on
|
||||
/// *size* — all it could do before — is a coin flip the moment two heads share a mode, and it
|
||||
/// resolved wrong on-glass once already (GNOME, a dummy HDMI beside the virtual primary: the seat
|
||||
/// cursor never entered the streamed monitor). These are the two keys that actually identify a
|
||||
/// region: the protocol's own `mapping_id`, and the origin (two outputs can share a size; they can
|
||||
/// never share a top-left).
|
||||
///
|
||||
/// Best-effort by design: an anchor that matches no region warns and falls back to the size ladder,
|
||||
/// because the region set is the truth and the anchor is our belief about it.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct AbsoluteAnchor {
|
||||
/// The target output's top-left in the compositor's global logical space.
|
||||
pub origin: Option<(i32, i32)>,
|
||||
/// The EI `mapping_id` of the target output, when the capture side knows it — the protocol's
|
||||
/// blessed way to correlate a region with a video stream, so it wins over the origin.
|
||||
pub mapping_id: Option<String>,
|
||||
}
|
||||
|
||||
impl AbsoluteAnchor {
|
||||
/// Nothing to match on — treated as "no anchor" so callers can build one unconditionally.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.origin.is_none() && self.mapping_id.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// The current absolute-coordinate anchor. A `RwLock` rather than an env var: the injector is
|
||||
/// host-lifetime and lives behind a channel, so a *session* can only reach it through process
|
||||
/// state — and process state that is typed and lock-guarded beats the `set_var` pattern the
|
||||
/// backend-select still uses (security-review 2026-06-28 #7).
|
||||
static ABSOLUTE_ANCHOR: std::sync::RwLock<Option<AbsoluteAnchor>> = std::sync::RwLock::new(None);
|
||||
|
||||
/// Anchor absolute coordinates at a specific output. `None` (the default) keeps the size-matched
|
||||
/// behavior.
|
||||
///
|
||||
/// ⚠️ **This is a HOST-level pin, not per-session state.** The injector is host-lifetime and every
|
||||
/// concurrent session's input flows through the same one, so an anchor set per session would apply
|
||||
/// to all of them — the last connect silently re-aiming everyone else's pointer. That is fine for
|
||||
/// what this exists for (`PUNKTFUNK_CAPTURE_MONITOR`, a host-wide pin — the host-pinned decision of
|
||||
/// record in `design/per-monitor-portal-capture.md` §5.3) and wrong for anything per-client. A
|
||||
/// per-session anchor needs the injector to become session-aware first; don't call this from a
|
||||
/// session path until it is.
|
||||
pub fn set_absolute_anchor(anchor: Option<AbsoluteAnchor>) {
|
||||
let anchor = anchor.filter(|a| !a.is_empty());
|
||||
tracing::debug!(?anchor, "input: absolute-coordinate anchor set");
|
||||
*ABSOLUTE_ANCHOR.write().unwrap_or_else(|e| e.into_inner()) = anchor;
|
||||
}
|
||||
|
||||
/// The anchor an injector should map absolute coordinates into, if any.
|
||||
pub fn absolute_anchor() -> Option<AbsoluteAnchor> {
|
||||
ABSOLUTE_ANCHOR
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Pick the injection backend for the current session. gamescope hosts its own EIS server (no
|
||||
/// portal), so a gamescope session injects directly into it. wlroots/Sway only implements the
|
||||
/// ScreenCast portal (no RemoteDesktop), so libei can't run there — use the wlr virtual-input
|
||||
|
||||
@@ -77,7 +77,7 @@ pub use session::{session_epoch, try_recover_session};
|
||||
#[path = "vdisplay/routing.rs"]
|
||||
pub(crate) mod routing;
|
||||
pub use routing::{
|
||||
apply_input_env, managed_session_available, restore_managed_session,
|
||||
apply_input_env, managed_session_available, restore_managed_session, restore_takeover_now,
|
||||
restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -288,8 +288,35 @@ pub fn rebuild_probe_active() -> bool {
|
||||
REBUILD_PROBES.load(std::sync::atomic::Ordering::SeqCst) > 0
|
||||
}
|
||||
|
||||
/// The monitor this host mirrors instead of creating a virtual display, or `None` for the normal
|
||||
/// virtual-display path.
|
||||
///
|
||||
/// Precedence: **`PUNKTFUNK_CAPTURE_MONITOR` wins over the stored policy.** An appliance pins in
|
||||
/// `host.env` and must stay pinned there — a console click (or a stale settings file) should not be
|
||||
/// able to re-aim a machine whose operator declared the answer in its unit's environment. With the
|
||||
/// env unset, the console's persisted choice applies, which is what makes a picker possible at all:
|
||||
/// the env is read once at startup, so it could never be what a UI writes.
|
||||
///
|
||||
/// Read per `open` rather than cached, so a console change takes effect on the next session instead
|
||||
/// of at the next host restart.
|
||||
pub fn capture_monitor() -> Option<String> {
|
||||
if let Some(env) = pf_host_config::config().capture_monitor.as_deref() {
|
||||
return Some(env.to_string());
|
||||
}
|
||||
policy::prefs().get().capture_monitor
|
||||
}
|
||||
|
||||
/// Open the virtual-display driver for `compositor`.
|
||||
///
|
||||
/// A [`capture_monitor`] pin routes to the **mirror** backend instead: the host streams that
|
||||
/// physical head and creates no virtual display at all. Deliberately resolved here, at the one place
|
||||
/// every session opens a display, so the pin can't be honored on one plane and ignored on another —
|
||||
/// it is a host-wide setting (`design/per-monitor-portal-capture.md` §5.3).
|
||||
pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(connector) = capture_monitor() {
|
||||
return Ok(Box::new(mirror::MirrorDisplay::new(compositor, connector)?));
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
match compositor {
|
||||
@@ -321,6 +348,18 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the **mirror** backend for a specific monitor, bypassing the `PUNKTFUNK_CAPTURE_MONITOR`
|
||||
/// pin that [`open`] consults. For tools that name the head explicitly (`punktfunk-host
|
||||
/// mirror-test`) — the pin can't serve them, since `pf_host_config` parses the environment once at
|
||||
/// startup, so a tool setting the variable for itself would be reading a snapshot taken before it.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn open_mirror(compositor: Compositor, connector: &str) -> Result<Box<dyn VirtualDisplay>> {
|
||||
Ok(Box::new(mirror::MirrorDisplay::new(
|
||||
compositor,
|
||||
connector.to_string(),
|
||||
)?))
|
||||
}
|
||||
|
||||
/// Readiness probe for `compositor`: is it up and able to create a virtual output *right
|
||||
/// now*? A session-bringup script polls this (via `punktfunk-host probe-compositor`) to gate
|
||||
/// on actual readiness instead of racing the compositor with a blind sleep.
|
||||
@@ -359,6 +398,21 @@ pub fn probe(compositor: Compositor) -> Result<()> {
|
||||
#[path = "vdisplay/policy.rs"]
|
||||
pub mod policy;
|
||||
|
||||
// Read-only physical-monitor enumeration (the heads the compositor ALREADY has — not ours), for
|
||||
// pinning capture at one of them + the console picker. Platform-neutral facade; the per-backend
|
||||
// reads live beside the code that already speaks each dialect. See
|
||||
// `design/per-monitor-portal-capture.md` §5.1.
|
||||
#[path = "vdisplay/monitors.rs"]
|
||||
pub mod monitors;
|
||||
|
||||
// The monitor-mirror backend: stream a head the compositor ALREADY has (the
|
||||
// `PUNKTFUNK_CAPTURE_MONITOR` pin) instead of creating one. Implements `VirtualDisplay` so the
|
||||
// session machinery is unchanged, but reports `DisplayOwnership::External` so none of the
|
||||
// virtual-display lifecycle policy is applied to someone else's monitor.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/mirror.rs"]
|
||||
mod mirror;
|
||||
|
||||
// The pure per-display lifecycle state machine (refcount + linger + pin), platform-neutral and
|
||||
// property-tested; the registry executes the side effects its transitions dictate.
|
||||
#[path = "vdisplay/lifecycle.rs"]
|
||||
|
||||
@@ -181,9 +181,14 @@ pub fn panel_off_except(exclude_gdi: &str) -> u32 {
|
||||
acked += set_power(m.hmon, &m.device, POWER_OFF);
|
||||
}
|
||||
if acked == 0 {
|
||||
tracing::debug!(
|
||||
"DDC/CI: no physical panel accepted the DPMS-off command \
|
||||
(no DDC/CI-capable panel besides the virtual display)"
|
||||
// INFO, not debug: the user opted into this axis, so "it did nothing" is an answer they
|
||||
// asked for. The common case is a laptop — internal eDP/LVDS panels have NO DDC/CI at
|
||||
// all (their brightness/power runs over the driver's own channel), so `ddc_power_off`
|
||||
// is structurally a no-op for them (reporter feedback 2026-07-27).
|
||||
tracing::info!(
|
||||
"DDC/CI: no panel accepted the DPMS-off command — the ddc_power_off axis did \
|
||||
nothing on this display set (internal eDP/LVDS panels expose no DDC/CI; external \
|
||||
monitors may have it disabled in the OSD or dropped by a dock/KVM)"
|
||||
);
|
||||
}
|
||||
acked
|
||||
|
||||
@@ -71,14 +71,23 @@ static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(
|
||||
/// 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
|
||||
/// mtime of the `steamos-session-select` sentinel as of the takeover — 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>> =
|
||||
///
|
||||
/// Two levels of `Option`, because "no baseline" and "no sentinel" mean opposite things:
|
||||
/// * **outer `None`** — never baselined (no takeover this host lifetime). Nothing can read as an
|
||||
/// in-stream request: the sentinel is a permanent file, so any box whose user has EVER switched
|
||||
/// sessions has one, and comparing against a missing baseline made that ancient write look like
|
||||
/// a live "Switch to Desktop".
|
||||
/// * **`Some(None)`** — baselined while no sentinel existed yet; a later one was created inside the
|
||||
/// session, which IS a request.
|
||||
/// * **`Some(Some(t))`** — baselined at mtime `t`; anything newer is a request.
|
||||
static SESSION_SELECT_BASELINE: std::sync::Mutex<Option<Option<std::time::SystemTime>>> =
|
||||
std::sync::Mutex::new(None);
|
||||
|
||||
/// When [`honor_session_select_switch`] last ran. While recent, a managed (re)launch is refused —
|
||||
@@ -1155,8 +1164,8 @@ const DM_HELPER_PATHS: &[&str] = &[
|
||||
"/usr/lib/punktfunk/pf-dm-helper",
|
||||
];
|
||||
|
||||
/// Run the packaged DM helper (`stop` | `restore`) via pkexec. `false` when the helper isn't
|
||||
/// installed (tarball/old package), pkexec is missing, or polkit denies the action.
|
||||
/// Run the packaged DM helper (`stop` | `restore` | `linger`) via pkexec. `false` when the helper
|
||||
/// isn't installed (tarball/old package), pkexec is missing, or polkit denies the action.
|
||||
fn dm_helper(verb: &str) -> bool {
|
||||
let Some(helper) = DM_HELPER_PATHS
|
||||
.iter()
|
||||
@@ -1172,18 +1181,102 @@ fn dm_helper(verb: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `systemctl` on the SYSTEM bus, **never interactively**. Every privileged verb below runs on the
|
||||
/// stream's own thread — the capture-loss rebuild, or the restore worker — with no way to answer a
|
||||
/// question. Without `--no-ask-password`, systemctl asks polkit for interactive authorization, and
|
||||
/// on a box whose desktop session is still alive (the host is a `--user` unit inside it) polkit
|
||||
/// hands that to the session's agent: a password dialog on the box's OWN screen, which during a
|
||||
/// managed takeover is off or mid-switch. Nobody sees it, nobody answers it, and the call blocks
|
||||
/// while the rebuild budget burns — the takeover then lands after the session it was for already
|
||||
/// ended. `--no-ask-password` turns that into the immediate "interactive authentication required"
|
||||
/// failure the callers are written for, so the pkexec helper (`allow_any`, no agent needed) takes
|
||||
/// over instead of a dialog. Field-suspect in the 0.20.0 Nobara report (intermittent disconnect +
|
||||
/// a screen that never comes back), where the timing is a race against the KDE agent's own death.
|
||||
fn systemctl_system(args: &[&str]) -> bool {
|
||||
let mut cmd = Command::new("systemctl");
|
||||
cmd.arg("--no-ask-password").args(args);
|
||||
cmd.status().map(|s| s.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Would stopping the display manager also stop US? A packaged host runs as a `systemd --user`
|
||||
/// unit, so its lifetime hangs off the user manager — and the DM stop ends the user's last login
|
||||
/// session. logind then stops `user@<uid>.service` once `UserStopDelaySec` (10 s by default)
|
||||
/// elapses, taking the host with it: the stream dies mid-takeover, and nothing is left to restart
|
||||
/// the display manager, so the box stays dark until someone reaches a VT. **Field-proven on 0.20.0**
|
||||
/// (Nobara, 2026-07-27): DM stopped at 12:34:18.9, the user manager stopped the host at 12:34:29.0
|
||||
/// — 10.1 s, textbook `UserStopDelaySec`. It never showed on the repro VM because lingering was
|
||||
/// enabled there for the sessionless tests.
|
||||
///
|
||||
/// Lingering (`loginctl enable-linger` — which the KDE/GNOME/Arch setup docs already ask for) is
|
||||
/// what breaks the dependency: logind keeps the user manager up with no session at all. So ensure
|
||||
/// it BEFORE touching the DM, and refuse the takeover when it can't be ensured — the caller then
|
||||
/// degrades to attach, which mirrors the box's own session and never stops the DM.
|
||||
fn ensure_host_survives_dm_stop() -> bool {
|
||||
if !host_is_under_user_manager() {
|
||||
return true; // root / a system unit — the DM stop cannot reach us
|
||||
}
|
||||
if linger_enabled() {
|
||||
return true;
|
||||
}
|
||||
// `set-self-linger` is `allow_active` in logind's own policy, so a host started inside the
|
||||
// user's session can do this itself; a sessionless one (the packaged unit) goes through the
|
||||
// helper, whose grant is scoped to the calling uid.
|
||||
let uid = uid_string();
|
||||
let _ = Command::new("loginctl")
|
||||
.args(["--no-ask-password", "enable-linger", &uid])
|
||||
.status();
|
||||
if linger_enabled() || (dm_helper("linger") && linger_enabled()) {
|
||||
tracing::info!(
|
||||
uid,
|
||||
"enabled lingering for this user — the managed takeover stops the display manager, \
|
||||
which ends this login session, and without lingering logind would stop the host \
|
||||
along with it (`loginctl disable-linger` reverts it)"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Is this process's lifetime tied to a `systemd --user` manager (i.e. would logind's user-manager
|
||||
/// stop take us down)? Read from our own cgroup path.
|
||||
fn host_is_under_user_manager() -> bool {
|
||||
std::fs::read_to_string("/proc/self/cgroup")
|
||||
.as_deref()
|
||||
.map(cgroup_under_user_manager)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// [`host_is_under_user_manager`]'s test: does this `/proc/self/cgroup` content sit under a
|
||||
/// `user@<uid>.service` manager? Pure + unit-tested. A system unit
|
||||
/// (`/system.slice/punktfunk-host.service`) does not, and neither does a bare process started from
|
||||
/// a login shell (`/user.slice/user-1000.slice/session-2.scope`) — logind's user-manager stop only
|
||||
/// reaches units the user manager owns.
|
||||
fn cgroup_under_user_manager(cgroup: &str) -> bool {
|
||||
cgroup.contains("user@")
|
||||
}
|
||||
|
||||
/// Our uid as a string — what `loginctl` wants for a user argument.
|
||||
fn uid_string() -> String {
|
||||
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory.
|
||||
unsafe { libc::getuid() }.to_string()
|
||||
}
|
||||
|
||||
/// Is lingering on for this user (logind keeps the `--user` manager alive with no session)?
|
||||
fn linger_enabled() -> bool {
|
||||
Command::new("loginctl")
|
||||
.args(["show-user", &uid_string(), "-p", "Linger", "--value"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "yes")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
|
||||
/// the SYSTEM bus first — succeeds as root or under an operator polkit rule scoped to the DM unit
|
||||
/// (see docs); fails cleanly otherwise ("interactive authentication required") — then the
|
||||
/// packaged pkexec helper. `false` means no privilege path exists and the caller degrades to
|
||||
/// attach.
|
||||
fn try_stop_display_manager(dm: &str) -> bool {
|
||||
let direct = Command::new("systemctl")
|
||||
.args(["stop", dm])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
direct || dm_helper("stop")
|
||||
systemctl_system(&["stop", dm]) || dm_helper("stop")
|
||||
}
|
||||
|
||||
/// Restore the display manager: `reset-failed` (a relogin loop may have tripped the unit's start
|
||||
@@ -1192,15 +1285,8 @@ fn try_stop_display_manager(dm: &str) -> bool {
|
||||
/// operator polkit rule), then the packaged pkexec helper, whose `restore` verb performs the same
|
||||
/// two steps as root.
|
||||
fn restore_display_manager(dm: &str) -> bool {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["reset-failed", dm])
|
||||
.status();
|
||||
let direct = Command::new("systemctl")
|
||||
.args(["restart", dm])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
direct || dm_helper("restore")
|
||||
let _ = systemctl_system(&["reset-failed", dm]);
|
||||
systemctl_system(&["restart", dm]) || dm_helper("restore")
|
||||
}
|
||||
|
||||
/// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the
|
||||
@@ -1226,24 +1312,39 @@ fn session_select_mtime() -> Option<std::time::SystemTime> {
|
||||
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.
|
||||
/// Record the sentinel baseline, so a LATER write (the user's in-stream "Switch to Desktop") is
|
||||
/// distinguishable from the switch that led into this session. Taken at **takeover** (the moment
|
||||
/// [`STOPPED_DM`] is set, which is what arms the honor gate) and again at a successful launch: the
|
||||
/// switch INTO game mode writes the sentinel on its way in, and that write must never read as a
|
||||
/// request to go back out. Baselining only at launch left the window in between — a takeover whose
|
||||
/// launch failed, then a client retry inside the restore debounce — reading a months-old sentinel
|
||||
/// as a live request and pushing the box to the desktop the user never asked for.
|
||||
fn record_session_select_baseline() {
|
||||
*SESSION_SELECT_BASELINE
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = session_select_mtime();
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some(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.
|
||||
/// Did a session-select run inside the managed session since the baseline? 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
|
||||
sentinel_advanced(baseline, session_select_mtime())
|
||||
}
|
||||
|
||||
/// [`session_select_requested`]'s decision, as a pure function of the two readings (the
|
||||
/// unit-testable core). No baseline ⇒ no request: see [`SESSION_SELECT_BASELINE`] for why a
|
||||
/// missing baseline must not be read as "the sentinel appeared during the session".
|
||||
fn sentinel_advanced(
|
||||
baseline: Option<Option<std::time::SystemTime>>,
|
||||
now: Option<std::time::SystemTime>,
|
||||
) -> bool {
|
||||
match (baseline, now) {
|
||||
(Some(Some(base)), Some(now)) => now > base,
|
||||
(Some(None), Some(_)) => true, // no sentinel at baseline — created during the session
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -1395,6 +1496,19 @@ fn stop_autologin_sessions() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
let dm = dm.expect("!is_none_or ⇒ Some");
|
||||
// The DM stop ends this user's last login session. If our own lifetime hangs off the user
|
||||
// manager and lingering can't be turned on, that stop kills the host ~10s later — with the
|
||||
// box's display manager down and nobody left to bring it back. Degrading to attach is
|
||||
// strictly better than a black screen that needs a VT to recover.
|
||||
if !ensure_host_survives_dm_stop() {
|
||||
bail!(
|
||||
"stopping {dm} ends this user's last login session, and without lingering logind \
|
||||
would stop the user manager — and this host with it — about 10s later, leaving \
|
||||
the box with no display manager and nothing to restore it; enabling lingering \
|
||||
failed, so the managed takeover is unavailable (run `sudo loginctl enable-linger \
|
||||
$USER` once, as the setup docs ask, then reconnect)"
|
||||
);
|
||||
}
|
||||
if !try_stop_display_manager(&dm) {
|
||||
bail!(
|
||||
"the box's gaming session is driven by {dm}, which does not survive a masked \
|
||||
@@ -1408,6 +1522,11 @@ fn stop_autologin_sessions() -> Result<()> {
|
||||
%dm,
|
||||
"freed Steam: stopped the display manager for this stream (mask-fragile DM flavor)"
|
||||
);
|
||||
// Baseline the switch sentinel HERE, not just at a successful launch: setting STOPPED_DM
|
||||
// is what arms the honor gate, so from this instant an unbaselined sentinel would read as
|
||||
// an in-stream "Switch to Desktop" — including the write from the switch that just brought
|
||||
// the box INTO game mode. A successful launch re-baselines (tighter still).
|
||||
record_session_select_baseline();
|
||||
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm);
|
||||
}
|
||||
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
|
||||
@@ -1567,21 +1686,7 @@ fn restore_delay() -> Option<Duration> {
|
||||
/// the managed session is pinned (gaming-rig). No-op when nothing was stolen (non-Bazzite / headless
|
||||
/// box). Idempotent / safe to call on every session end.
|
||||
pub fn schedule_restore_tv_session() {
|
||||
let nothing_to_restore = STOPPED_AUTOLOGIN
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_empty()
|
||||
&& !*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 !takeover_live() {
|
||||
return; // nothing was taken over → nothing to restore (also the non-managed path)
|
||||
}
|
||||
match restore_delay() {
|
||||
@@ -1604,6 +1709,48 @@ pub fn schedule_restore_tv_session() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is anything of the box's own session ours right now — an autologin unit we stopped, a stopped
|
||||
/// display manager, a SteamOS target we re-pointed, or a managed session we launched beside a live
|
||||
/// desktop? The precondition for every restore path.
|
||||
fn takeover_live() -> bool {
|
||||
!STOPPED_AUTOLOGIN
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_empty()
|
||||
|| *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner())
|
||||
|| STOPPED_DM
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_some()
|
||||
// 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_some()
|
||||
}
|
||||
|
||||
/// Give the box its own session back **now**, synchronously — the host is going away (SIGTERM from
|
||||
/// `systemctl --user stop`/`restart`, a package update, Ctrl-C) and a live takeover must not
|
||||
/// outlive it. On a DM-flavor takeover the display manager is STOPPED: nothing else on the box
|
||||
/// will ever restart it, and the persisted crash-restore state lives in `$XDG_RUNTIME_DIR`, which
|
||||
/// logind removes along with the user manager — so not even the next host start can heal it. The
|
||||
/// box would stay dark until someone reached a VT.
|
||||
///
|
||||
/// Deliberately ignores the keep-alive policy that [`schedule_restore_tv_session`] honors:
|
||||
/// `keep_alive=forever` pins a session for the NEXT client, which is meaningless once the host
|
||||
/// that would serve them is exiting. No-op when nothing was taken over.
|
||||
pub fn restore_takeover_now() {
|
||||
if !takeover_live() {
|
||||
return;
|
||||
}
|
||||
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None; // doing it right here
|
||||
tracing::info!("gamescope: host is shutting down — restoring the box's own session first");
|
||||
do_restore_tv_session();
|
||||
}
|
||||
|
||||
/// Does any DRM connector report a physically `connected` display? Scans
|
||||
/// `/sys/class/drm/*/status` — only connector nodes (`card0-eDP-1`, `card0-HDMI-A-1`, …) have a
|
||||
/// `status` file, so the bare `cardN` device dirs and `renderD*` nodes filter themselves out. A
|
||||
@@ -2147,10 +2294,51 @@ impl Drop for GamescopeProc {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
cgroup_is_punktfunk_owned, connected_connector_under, display_manager_unit_under,
|
||||
dm_survives_masked_unit, is_steam_launch, nested_wrapper_script, shape_dedicated_command,
|
||||
cgroup_is_punktfunk_owned, cgroup_under_user_manager, connected_connector_under,
|
||||
display_manager_unit_under, dm_survives_masked_unit, is_steam_launch,
|
||||
nested_wrapper_script, sentinel_advanced, shape_dedicated_command,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn user_manager_lifetime_detection() {
|
||||
// The packaged host: a `--user` unit, so logind's user-manager stop takes it down with the
|
||||
// login session the DM stop ends — this is the case that needs lingering.
|
||||
assert!(cgroup_under_user_manager(
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-host.service\n"
|
||||
));
|
||||
assert!(cgroup_under_user_manager(
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/session.slice/punktfunk-gamescope.service\n"
|
||||
));
|
||||
// A system unit outlives every session — the DM stop cannot reach it.
|
||||
assert!(!cgroup_under_user_manager(
|
||||
"0::/system.slice/punktfunk-host.service\n"
|
||||
));
|
||||
// Started from a login shell: owned by the session scope, not the user manager.
|
||||
assert!(!cgroup_under_user_manager(
|
||||
"0::/user.slice/user-1000.slice/session-2.scope\n"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_select_sentinel_needs_a_baseline() {
|
||||
let t0 = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000);
|
||||
let t1 = t0 + std::time::Duration::from_secs(1);
|
||||
// Never baselined: the sentinel is a permanent file, so a box whose user EVER switched
|
||||
// sessions has one — that ancient write is not a live "Switch to Desktop" request. This is
|
||||
// the case that pushed a Nobara box to the desktop after a failed managed launch.
|
||||
assert!(!sentinel_advanced(None, Some(t0)));
|
||||
assert!(!sentinel_advanced(None, None));
|
||||
// Baselined with no sentinel yet, then one appeared inside the session: a real request.
|
||||
assert!(sentinel_advanced(Some(None), Some(t0)));
|
||||
assert!(!sentinel_advanced(Some(None), None));
|
||||
// Baselined at an mtime: only a NEWER one is the user's in-stream switch. The write that
|
||||
// brought the box into game mode is the baseline itself, so it reads as no request.
|
||||
assert!(sentinel_advanced(Some(Some(t0)), Some(t1)));
|
||||
assert!(!sentinel_advanced(Some(Some(t0)), Some(t0)));
|
||||
assert!(!sentinel_advanced(Some(Some(t1)), Some(t0)));
|
||||
assert!(!sentinel_advanced(Some(Some(t0)), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_wrapper_script_shapes() {
|
||||
let relay = std::path::Path::new("/run/user/1000/pf-ei");
|
||||
|
||||
@@ -174,25 +174,12 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
set_monitor_rule(&name, mode).with_context(|| format!("set monitor rule for {name}"))?;
|
||||
|
||||
// Steer xdph's custom picker at our new output, then run the portal handshake on its own
|
||||
// thread (it parks to keep the cast alive, like the other backends).
|
||||
ensure_xdph_config()?;
|
||||
let sel = selection_file();
|
||||
std::fs::write(&sel, picker_selection_line(&name))
|
||||
.with_context(|| format!("write {sel}"))?;
|
||||
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
let hw_cursor = self.hw_cursor;
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-hypr-vout".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
|
||||
.context("spawn hyprland portal thread")?;
|
||||
|
||||
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {name} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {name}"),
|
||||
// thread (it parks to keep the cast alive, like the other backends). Serialized: the
|
||||
// selection is one per-user file, so a concurrent session's write between ours and xdph's
|
||||
// read would silently capture the wrong output (see `SELECTION_LOCK`).
|
||||
let (fd, node_id, stop) = {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
select_and_cast(&name, self.hw_cursor)?
|
||||
};
|
||||
tracing::info!(
|
||||
node_id,
|
||||
@@ -272,6 +259,102 @@ fn hyprctl(args: &[&str]) -> Result<String> {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Serializes **write-the-selection → complete-the-handshake**, process-wide — see the wlroots
|
||||
/// backend's `SELECTION_LOCK`. The xdph selection is likewise one per-user file, so a concurrent
|
||||
/// write between ours and xdph's read would silently steer capture at the other session's output.
|
||||
static SELECTION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Point xdph's custom picker at `output` and run the ScreenCast handshake, returning the portal fd
|
||||
/// + node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
|
||||
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc<AtomicBool>)> {
|
||||
ensure_xdph_config()?;
|
||||
let sel = selection_file();
|
||||
std::fs::write(&sel, picker_selection_line(output)).with_context(|| format!("write {sel}"))?;
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-hypr-cast".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
|
||||
.context("spawn hyprland portal thread")?;
|
||||
match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok((fd, node_id))) => Ok((fd, node_id, stop)),
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an **existing** Hyprland monitor — the monitor-mirror path
|
||||
/// (`design/per-monitor-portal-capture.md` L3): the same custom-picker mechanism the virtual-output
|
||||
/// path uses, pointed at a physical connector, so no GUI picker is involved.
|
||||
///
|
||||
/// The keepalive stops the cast only — the monitor is Hyprland's, not ours.
|
||||
pub(crate) fn stream_existing_output(
|
||||
connector: &str,
|
||||
hw_cursor: bool,
|
||||
) -> Result<crate::mirror::MirrorStream> {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?;
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
keepalive: Box::new(StopGuard(stop)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Every head Hyprland reports, for [`crate::monitors::list`].
|
||||
///
|
||||
/// `hyprctl -j monitors all` (rather than plain `monitors`) so **disabled** heads are listed too —
|
||||
/// a picker that silently omits the monitor the operator is trying to name is worse than one that
|
||||
/// shows it greyed out. Hyprland reports geometry post-transform in logical pixels, which is the
|
||||
/// space `crate::monitors` documents.
|
||||
pub(crate) fn list_monitors() -> Result<Vec<crate::monitors::PhysicalMonitor>> {
|
||||
let raw = hyprctl(&["-j", "monitors", "all"])?;
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&raw).context("parse hyprctl -j monitors all")?;
|
||||
let mut out: Vec<_> = parsed
|
||||
.as_array()
|
||||
.context("hyprctl monitors: not an array")?
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let connector = m.get("name")?.as_str()?.to_string();
|
||||
let num = |k: &str| m.get(k).and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
// Hyprland's `description` is already a "make model (connector)" string; treat it as
|
||||
// the make and let the shared helper drop it when it is empty/Unknown.
|
||||
let description = crate::monitors::describe(
|
||||
m.get("description").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"",
|
||||
&connector,
|
||||
);
|
||||
Some(crate::monitors::PhysicalMonitor {
|
||||
connector,
|
||||
description,
|
||||
width: num("width").max(0) as u32,
|
||||
height: num("height").max(0) as u32,
|
||||
// `refreshRate` is Hz as a float.
|
||||
refresh_mhz: (m.get("refreshRate").and_then(|v| v.as_f64()).unwrap_or(0.0) * 1000.0)
|
||||
as u32,
|
||||
x: num("x") as i32,
|
||||
y: num("y") as i32,
|
||||
scale: m
|
||||
.get("scale")
|
||||
.and_then(|v| v.as_f64())
|
||||
.filter(|s| *s > 0.0)
|
||||
.unwrap_or(1.0),
|
||||
primary: m.get("focused").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
enabled: !m.get("disabled").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
// Our headless outputs are named `PF-<n>` (see `next_output_name`).
|
||||
managed: m
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|n| n.starts_with("PF-")),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|m| (m.x, m.y, m.connector.clone()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Run a `hyprctl` **dispatch** command (`output …`, `keyword …`, `eval …`) that reports success by
|
||||
/// printing `ok`. hyprctl often exits 0 even when the command is rejected, printing the error to
|
||||
/// stdout, so treat a known error marker as failure (this is also how [`set_monitor_rule`] tells the
|
||||
|
||||
@@ -32,6 +32,7 @@ use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use wayland_client::protocol::wl_output::{self, WlOutput};
|
||||
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||||
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
|
||||
|
||||
@@ -914,6 +915,10 @@ struct State {
|
||||
node_id: Option<u32>,
|
||||
failed: Option<String>,
|
||||
closed: bool,
|
||||
/// Every `wl_output` KWin advertises, keyed by the proxy, with its connector name once the
|
||||
/// `name` event arrives. Only the monitor-mirror path ([`stream_existing_output`]) needs these
|
||||
/// — `stream_output` takes a `wl_output` object, so the connector has to be resolved to one.
|
||||
outputs: Vec<(WlOutput, Option<String>)>,
|
||||
}
|
||||
|
||||
impl Dispatch<WlRegistry, ()> for State {
|
||||
@@ -934,6 +939,34 @@ impl Dispatch<WlRegistry, ()> for State {
|
||||
if interface == Screencast::interface().name {
|
||||
let v = version.min(MAX_VERSION);
|
||||
state.screencast = Some(registry.bind::<Screencast, _, _>(name, v, qh, ()));
|
||||
} else if interface == WlOutput::interface().name {
|
||||
// v4 is where `wl_output.name` (the connector) arrives; bind at least that when the
|
||||
// compositor offers it, else bind what it has and let the resolve fail loudly
|
||||
// rather than mirroring an unidentifiable head.
|
||||
let v = version.min(WL_OUTPUT_MAX_VERSION);
|
||||
let out = registry.bind::<WlOutput, _, _>(name, v, qh, ());
|
||||
state.outputs.push((out, None));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `wl_output` version we bind at: 4 brings the `name` event carrying the connector
|
||||
/// (`DP-1`, …) — the only way to tell KWin's outputs apart on this connection.
|
||||
const WL_OUTPUT_MAX_VERSION: u32 = 4;
|
||||
|
||||
impl Dispatch<WlOutput, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
output: &WlOutput,
|
||||
event: wl_output::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_output::Event::Name { name } = event {
|
||||
if let Some(slot) = state.outputs.iter_mut().find(|(o, _)| o == output) {
|
||||
slot.1 = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -988,6 +1021,55 @@ fn virtual_output_thread(
|
||||
}
|
||||
}
|
||||
|
||||
/// Start recording the existing KWin output named `connector` (the monitor-mirror path), returning
|
||||
/// its PipeWire node id and the keepalive whose drop stops the recording.
|
||||
///
|
||||
/// `hw_cursor` selects the pointer mode exactly as the virtual-output path does: metadata for a
|
||||
/// cursor-channel session, embedded otherwise, so the cursor behaves the same whichever source a
|
||||
/// session is on.
|
||||
pub(crate) fn stream_existing_output(
|
||||
connector: &str,
|
||||
hw_cursor: bool,
|
||||
) -> Result<crate::mirror::MirrorStream> {
|
||||
let pointer_mode = if hw_cursor {
|
||||
POINTER_METADATA
|
||||
} else {
|
||||
POINTER_EMBEDDED
|
||||
};
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
let connector_thread = connector.to_string();
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-kwin-mirror".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = run_existing(&connector_thread, pointer_mode, &setup_tx, &stop_thread) {
|
||||
let _ = setup_tx.send(Err(format!("{e:#}")));
|
||||
}
|
||||
})
|
||||
.context("spawn KWin monitor-mirror thread")?;
|
||||
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(e)) => bail!("KWin monitor mirror failed: {e}"),
|
||||
Err(_) => bail!("timed out recording the KWin output {connector:?}"),
|
||||
};
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
// KWin publishes on the user's own PipeWire daemon — no portal remote to carry.
|
||||
remote_fd: None,
|
||||
keepalive: Box::new(StopOnDrop(stop)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Stops the mirror thread (and thus the recording) when the capturer drops it.
|
||||
struct StopOnDrop(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for StopOnDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Readiness probe: connect to the KWin Wayland socket, roundtrip the registry, and confirm
|
||||
/// the privileged `zkde_screencast` global is actually advertised. This is exactly what
|
||||
/// [`run`] needs before it can create a virtual output, so a session-bringup script can poll
|
||||
@@ -1019,6 +1101,106 @@ pub fn is_available() -> bool {
|
||||
probe().is_ok()
|
||||
}
|
||||
|
||||
/// Stream an **existing** KWin output — the monitor-mirror path
|
||||
/// (`design/per-monitor-portal-capture.md` L1). Same privileged global and the same thread/keepalive
|
||||
/// shape as the virtual-output path; `stream_output` simply takes a `wl_output` instead of minting
|
||||
/// one, so there is no dialog, no portal, and no chooser: the connector name IS the selection.
|
||||
///
|
||||
/// Returns the PipeWire node id. The thread parks until `stop`, holding the Wayland connection that
|
||||
/// is the cast's lifetime — dropping it stops the recording and leaves the monitor untouched (we
|
||||
/// never created it, so there is nothing to tear down; §7.1).
|
||||
fn run_existing(
|
||||
connector: &str,
|
||||
pointer_mode: u32,
|
||||
setup_tx: &Sender<Result<u32, String>>,
|
||||
stop: &AtomicBool,
|
||||
) -> Result<()> {
|
||||
let conn = Connection::connect_to_env()
|
||||
.context("connect to KWin Wayland (is WAYLAND_DISPLAY set to the KWin socket?)")?;
|
||||
let mut queue = conn.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
let _registry = conn.display().get_registry(&qh, ());
|
||||
|
||||
let mut state = State::default();
|
||||
// Two roundtrips: the first processes the globals (binding screencast + every wl_output), the
|
||||
// second drains each output's property burst — the `name` event we resolve the connector by.
|
||||
queue.roundtrip(&mut state).context("registry roundtrip")?;
|
||||
queue
|
||||
.roundtrip(&mut state)
|
||||
.context("wl_output property roundtrip")?;
|
||||
|
||||
let screencast = state.screencast.clone().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \
|
||||
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
|
||||
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless test)"
|
||||
)
|
||||
})?;
|
||||
|
||||
// Resolve the connector to a bound wl_output. A miss is a hard error naming what IS there:
|
||||
// mirroring some other monitor because the requested one is unplugged shows the operator a
|
||||
// screen they did not ask for, which is worse than a session that refuses with a reason.
|
||||
let named: Vec<&str> = state
|
||||
.outputs
|
||||
.iter()
|
||||
.filter_map(|(_, n)| n.as_deref())
|
||||
.collect();
|
||||
let output = state
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|(_, n)| n.as_deref() == Some(connector))
|
||||
.or_else(|| {
|
||||
state.outputs.iter().find(|(_, n)| {
|
||||
n.as_deref()
|
||||
.is_some_and(|n| n.eq_ignore_ascii_case(connector))
|
||||
})
|
||||
})
|
||||
.map(|(o, _)| o.clone())
|
||||
.ok_or_else(|| {
|
||||
if named.is_empty() {
|
||||
anyhow!(
|
||||
"KWin advertised no named wl_output (needs wl_output v4 for the connector \
|
||||
name) — cannot mirror {connector:?}"
|
||||
)
|
||||
} else {
|
||||
anyhow!(
|
||||
"KWin has no output named {connector:?} — it has: {}",
|
||||
named.join(", ")
|
||||
)
|
||||
}
|
||||
})?;
|
||||
|
||||
let stream = screencast.stream_output(&output, pointer_mode, &qh, ());
|
||||
tracing::info!(
|
||||
connector,
|
||||
embedded_pointer = pointer_mode != POINTER_METADATA,
|
||||
"KWin: recording an existing output; awaiting PipeWire node"
|
||||
);
|
||||
|
||||
let node_id = loop {
|
||||
queue
|
||||
.blocking_dispatch(&mut state)
|
||||
.context("wayland dispatch (awaiting created)")?;
|
||||
if let Some(node) = state.node_id {
|
||||
break node;
|
||||
}
|
||||
if let Some(e) = state.failed.take() {
|
||||
bail!("stream_output failed: {e}");
|
||||
}
|
||||
if state.closed {
|
||||
bail!("KWin closed the stream before it was created");
|
||||
}
|
||||
};
|
||||
setup_tx
|
||||
.send(Ok(node_id))
|
||||
.map_err(|_| anyhow!("monitor-mirror opener went away"))?;
|
||||
|
||||
park_until_stopped(&conn, &mut queue, &mut state, stop, connector, node_id)?;
|
||||
stream.close();
|
||||
let _ = conn.flush();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(
|
||||
width: u32,
|
||||
height: u32,
|
||||
@@ -1080,15 +1262,31 @@ fn run(
|
||||
.send(Ok(node_id))
|
||||
.map_err(|_| anyhow!("virtual-output opener went away"))?;
|
||||
|
||||
// Keep the connection (and thus the virtual output) alive until told to stop, observing
|
||||
// `closed`. blocking_dispatch can't be interrupted, so poll the connection fd with a short
|
||||
// timeout so `stop` is honored within ~200 ms.
|
||||
park_until_stopped(&conn, &mut queue, &mut state, stop, name, node_id)?;
|
||||
|
||||
// Best-effort clean teardown; dropping the connection also makes KWin reclaim the output.
|
||||
stream.close();
|
||||
let _ = conn.flush();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keep the connection (and thus the stream) alive until told to stop, observing `closed`.
|
||||
/// `blocking_dispatch` can't be interrupted, so poll the connection fd with a short timeout and
|
||||
/// honor `stop` within ~200 ms. Shared by the virtual-output and monitor-mirror paths — for a
|
||||
/// virtual output this connection IS the output's lifetime; for a mirror it is only the
|
||||
/// recording's, and the monitor itself is untouched either way.
|
||||
fn park_until_stopped(
|
||||
conn: &Connection,
|
||||
queue: &mut wayland_client::EventQueue<State>,
|
||||
state: &mut State,
|
||||
stop: &AtomicBool,
|
||||
output: &str,
|
||||
node_id: u32,
|
||||
) -> Result<()> {
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
queue
|
||||
.dispatch_pending(&mut state)
|
||||
.context("dispatch_pending")?;
|
||||
queue.dispatch_pending(state).context("dispatch_pending")?;
|
||||
if state.closed {
|
||||
tracing::warn!(output = %name, node_id, "KWin closed the virtual-output stream");
|
||||
tracing::warn!(output = %output, node_id, "KWin closed the screencast stream");
|
||||
break;
|
||||
}
|
||||
conn.flush().context("wayland flush")?;
|
||||
@@ -1110,10 +1308,6 @@ fn run(
|
||||
let _ = guard.read();
|
||||
} // else: timeout or signal — drop the guard, re-check `stop`
|
||||
}
|
||||
|
||||
// Best-effort clean teardown; dropping the connection also makes KWin reclaim the output.
|
||||
stream.close();
|
||||
let _ = conn.flush();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,9 @@ pub mod management {
|
||||
}
|
||||
|
||||
use device::kde_output_device_mode_v2::{Event as ModeEvent, KdeOutputDeviceModeV2 as DeviceMode};
|
||||
use device::kde_output_device_registry_v2::{
|
||||
Event as RegistryEvent, KdeOutputDeviceRegistryV2 as DeviceRegistry,
|
||||
};
|
||||
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::{
|
||||
@@ -82,10 +85,16 @@ use management::kde_output_management_v2::KdeOutputManagementV2 as OutputManagem
|
||||
/// always in range on any KWin that advertises the globals.
|
||||
const MGMT_MAX: u32 = 22;
|
||||
const DEVICE_MAX: u32 = 24;
|
||||
/// `kde_output_device_registry_v2` — the newer way KWin hands out output devices (verified live on
|
||||
/// KWin 6.7.3, which advertises this at v23 and NO per-output `kde_output_device_v2` globals).
|
||||
const DEVICE_REGISTRY_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;
|
||||
/// The opcode of `kde_output_device_registry_v2.output` — the event that creates a child
|
||||
/// `kde_output_device_v2`. `finished` is event 0, `output` is event 1.
|
||||
const REGISTRY_OUTPUT_EVENT_OPCODE: u16 = 1;
|
||||
|
||||
/// 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.
|
||||
@@ -131,6 +140,14 @@ struct DeviceState {
|
||||
name: Option<String>,
|
||||
uuid: Option<String>,
|
||||
enabled: bool,
|
||||
/// Top-left in the compositor's global logical space, from the `geometry` event — the key that
|
||||
/// makes a head identifiable when two of them share a size (`monitors::PhysicalMonitor`).
|
||||
position: (i32, i32),
|
||||
/// `make` / `model` from the same event, for the picker label.
|
||||
make: Option<String>,
|
||||
model: Option<String>,
|
||||
/// Logical scale from the `scale` event. `None` ⇒ the protocol's documented default of 1.
|
||||
scale: Option<f64>,
|
||||
/// 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`].
|
||||
@@ -148,6 +165,9 @@ struct DeviceState {
|
||||
struct State {
|
||||
management: Option<OutputManagement>,
|
||||
mgmt_name_version: Option<(u32, u32)>,
|
||||
/// The `kde_output_device_registry_v2` on KWin ≥ 6.7, held so it keeps announcing outputs for
|
||||
/// the life of the session (dropping it would end the announcements).
|
||||
device_registry: Option<DeviceRegistry>,
|
||||
devices: HashMap<ObjectId, DeviceState>,
|
||||
/// mode object id → `(width, height, refresh_mHz)`.
|
||||
mode_dims: HashMap<ObjectId, (u32, u32, u32)>,
|
||||
@@ -185,6 +205,17 @@ impl Dispatch<WlRegistry, ()> for State {
|
||||
let dev = registry.bind::<OutputDevice, _, _>(name, v, qh, name);
|
||||
let id = dev.id();
|
||||
state.devices.entry(id).or_default().proxy = Some(dev);
|
||||
} else if interface == DeviceRegistry::interface().name {
|
||||
// KWin ≥ 6.7 (Plasma 6.7.3 verified) no longer advertises ONE
|
||||
// `kde_output_device_v2` global per output — it advertises this registry and
|
||||
// hands the devices out through its `output` events instead. Binding it is what
|
||||
// makes this whole module work on a current KWin: without it the device list
|
||||
// comes back EMPTY, which reads as "no outputs" and silently degrades every
|
||||
// topology apply to the `kscreen-doctor` shell-out this module exists to avoid.
|
||||
// Both models are kept — the per-output globals are still what older KWin sends.
|
||||
let v = version.min(DEVICE_REGISTRY_MAX);
|
||||
state.device_registry =
|
||||
Some(registry.bind::<DeviceRegistry, _, _>(name, v, qh, ()));
|
||||
}
|
||||
}
|
||||
wl_registry::Event::GlobalRemove { .. } => {}
|
||||
@@ -193,6 +224,30 @@ impl Dispatch<WlRegistry, ()> for State {
|
||||
}
|
||||
}
|
||||
|
||||
/// The device registry hands out one `kde_output_device_v2` per output via its `output` event
|
||||
/// (a `new_id`, so the child is created by the `event_created_child!` binding below). Devices that
|
||||
/// arrive this way have no global `name` number — the newest-wins supersede tie-break uses 0 for
|
||||
/// them, which is fine: that tie-break only matters for the per-output-global model.
|
||||
impl Dispatch<DeviceRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &DeviceRegistry,
|
||||
event: RegistryEvent,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let RegistryEvent::Output { output } = event {
|
||||
let id = output.id();
|
||||
state.devices.entry(id).or_default().proxy = Some(output);
|
||||
}
|
||||
}
|
||||
|
||||
event_created_child!(State, DeviceRegistry, [
|
||||
REGISTRY_OUTPUT_EVENT_OPCODE => (OutputDevice, 0u32),
|
||||
]);
|
||||
}
|
||||
|
||||
// Management has no events.
|
||||
impl Dispatch<OutputManagement, ()> for State {
|
||||
fn event(
|
||||
@@ -237,6 +292,15 @@ impl Dispatch<OutputDevice, u32> for State {
|
||||
match event {
|
||||
DeviceEvent::Name { name } => entry.name = Some(name),
|
||||
DeviceEvent::Uuid { uuid } => entry.uuid = Some(uuid),
|
||||
DeviceEvent::Geometry {
|
||||
x, y, make, model, ..
|
||||
} => {
|
||||
entry.position = (x, y);
|
||||
entry.make = Some(make);
|
||||
entry.model = Some(model);
|
||||
}
|
||||
// `fixed` decodes to f64 in wayland-rs.
|
||||
DeviceEvent::Scale { factor } => entry.scale = Some(factor),
|
||||
DeviceEvent::Enabled { enabled } => entry.enabled = enabled != 0,
|
||||
DeviceEvent::Priority { priority } => entry.priority = Some(priority),
|
||||
DeviceEvent::CurrentMode { mode } => entry.current_mode = Some(mode.id()),
|
||||
@@ -349,6 +413,15 @@ impl Session {
|
||||
if !s.sync_barrier(deadline) {
|
||||
return None;
|
||||
}
|
||||
// Phase 3 (KWin ≥ 6.7, the registry model): the devices themselves only arrive as the
|
||||
// registry's `output` events during phase 2, so their property bursts are one round further
|
||||
// out than they are for per-output globals. One more barrier — skipped when no device is
|
||||
// still waiting for its `done`, so the classic path costs nothing.
|
||||
if s.state.device_registry.is_some() && s.state.devices.values().any(|d| !d.seen_done) {
|
||||
if !s.sync_barrier(deadline) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(s)
|
||||
}
|
||||
|
||||
@@ -455,6 +528,54 @@ fn mode_spec(dims: (u32, u32, u32)) -> String {
|
||||
/// treated as a physical to disable, and its primary is never stolen (first-slot-wins).
|
||||
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
|
||||
|
||||
/// Every head KWin reports, for [`crate::monitors::list`].
|
||||
///
|
||||
/// Reuses the same bounded enumerate-only session the topology path opens — no configuration is
|
||||
/// built and nothing is applied, so this is a pure read. A device that never completed its `done`
|
||||
/// burst is skipped rather than reported half-read (its geometry would be a guess, and geometry is
|
||||
/// exactly what callers key on).
|
||||
pub(crate) fn list_monitors() -> anyhow::Result<Vec<crate::monitors::PhysicalMonitor>> {
|
||||
let session = Session::open().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"KWin did not answer kde_output_management_v2 (not a KWin session, the protocol is \
|
||||
not advertised to this client, or the compositor is wedged)"
|
||||
)
|
||||
})?;
|
||||
let mut out: Vec<_> = session
|
||||
.state
|
||||
.devices
|
||||
.values()
|
||||
.filter(|d| d.seen_done)
|
||||
.filter_map(|d| {
|
||||
let connector = d.name.clone()?;
|
||||
let dims = session.current_dims(d);
|
||||
Some(crate::monitors::PhysicalMonitor {
|
||||
managed: connector.starts_with(MANAGED_PREFIX),
|
||||
description: crate::monitors::describe(
|
||||
d.make.as_deref().unwrap_or(""),
|
||||
d.model.as_deref().unwrap_or(""),
|
||||
&connector,
|
||||
),
|
||||
// A disabled output has no current mode — report 0s rather than inventing one.
|
||||
width: dims.map(|d| d.0).unwrap_or(0),
|
||||
height: dims.map(|d| d.1).unwrap_or(0),
|
||||
refresh_mhz: dims.map(|d| d.2).unwrap_or(0),
|
||||
x: d.position.0,
|
||||
y: d.position.1,
|
||||
scale: d.scale.filter(|s| *s > 0.0).unwrap_or(1.0),
|
||||
// KWin ranks outputs; 1 is the primary.
|
||||
primary: d.priority == Some(1),
|
||||
enabled: d.enabled,
|
||||
connector,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// The device globals arrive in bind order, which is not stable across runs; sort by desktop
|
||||
// position so a picker (and a log line) reads left-to-right the way the desk looks.
|
||||
out.sort_by_key(|m| (m.x, m.y, m.connector.clone()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
@@ -817,4 +938,12 @@ mod tests {
|
||||
fn mode_event_opcode_is_two() {
|
||||
assert_eq!(DEVICE_MODE_EVENT_OPCODE, 2);
|
||||
}
|
||||
|
||||
/// Same hazard for the registry's `output` event (`finished` is 0, `output` is 1): the
|
||||
/// `event_created_child!` binding hardcodes the opcode, so a reorder in the vendored XML would
|
||||
/// bind the child to the wrong event and produce outputs with no properties.
|
||||
#[test]
|
||||
fn registry_output_event_opcode_is_one() {
|
||||
assert_eq!(REGISTRY_OUTPUT_EVENT_OPCODE, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,6 +376,190 @@ fn session_thread(
|
||||
});
|
||||
}
|
||||
|
||||
/// Record an **existing** monitor by connector — the monitor-mirror path
|
||||
/// (`design/per-monitor-portal-capture.md` L2). Returns the PipeWire node id and the keepalive
|
||||
/// whose drop stops the recording.
|
||||
///
|
||||
/// Same private ScreenCast API as the virtual path, one call different: `RecordMonitor` instead of
|
||||
/// `RecordVirtual`. So it inherits what makes that path work headlessly — Mutter's *direct* D-Bus
|
||||
/// API needs no interactive approval, unlike the xdg portal a background service could never answer.
|
||||
///
|
||||
/// Deliberately **not** under [`TOPOLOGY_LOCK`]: that lock serializes operations which add/remove a
|
||||
/// monitor or apply a monitor configuration, and mirroring does neither. It creates nothing, changes
|
||||
/// no layout, and leaves the head exactly as its owner set it.
|
||||
pub(crate) fn stream_existing_output(
|
||||
connector: &str,
|
||||
hw_cursor: bool,
|
||||
) -> Result<crate::mirror::MirrorStream> {
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
let connector_thread = connector.to_string();
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-mutter-mirror".into())
|
||||
.spawn(move || mirror_thread(setup_tx, stop_thread, connector_thread, hw_cursor))
|
||||
.context("spawn Mutter monitor-mirror thread")?;
|
||||
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(e)) => bail!("Mutter monitor mirror failed: {e}"),
|
||||
Err(_) => bail!("timed out recording the Mutter output {connector:?}"),
|
||||
};
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
// Mutter's RecordMonitor node lives on the user's PipeWire daemon (like RecordVirtual).
|
||||
remote_fd: None,
|
||||
keepalive: Box::new(MirrorStop(stop)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Stops the mirror thread — and with it the recording — when the capturer drops it.
|
||||
struct MirrorStop(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for MirrorStop {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the D-Bus connection behind a mirrored monitor's cast: connect, hand back the node id,
|
||||
/// park until stopped, then `Stop` the session.
|
||||
fn mirror_thread(
|
||||
setup_tx: Sender<Result<u32, String>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
connector: String,
|
||||
hw_cursor: bool,
|
||||
) {
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(async move {
|
||||
let session = match connect_monitor(&connector, hw_cursor).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("{e:#}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = setup_tx.send(Ok(session.node_id));
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
// Stop the cast. Nothing else to undo: no virtual output was added, so there is no monitor
|
||||
// removal for Mutter to rebuild around — the SIGSEGV-adjacent teardown ordering the virtual
|
||||
// path has to observe simply doesn't arise here.
|
||||
let _ = session.rd_session.call_method("Stop", &()).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// The `RecordMonitor` handshake: RemoteDesktop session → ScreenCast session anchored to it →
|
||||
/// record `connector` → node id on `PipeWireStreamAdded` after `Start`.
|
||||
async fn connect_monitor(connector: &str, hw_cursor: bool) -> Result<MutterSession> {
|
||||
let conn = zbus::Connection::session()
|
||||
.await
|
||||
.context("connect session D-Bus")?;
|
||||
let rd = zbus::Proxy::new(
|
||||
&conn,
|
||||
BUS_RD,
|
||||
"/org/gnome/Mutter/RemoteDesktop",
|
||||
"org.gnome.Mutter.RemoteDesktop",
|
||||
)
|
||||
.await
|
||||
.context("RemoteDesktop proxy (is gnome-shell running?)")?;
|
||||
let rd_path: OwnedObjectPath = rd
|
||||
.call("CreateSession", &())
|
||||
.await
|
||||
.context("RemoteDesktop.CreateSession")?;
|
||||
let rd_session = zbus::Proxy::new(
|
||||
&conn,
|
||||
BUS_RD,
|
||||
rd_path,
|
||||
"org.gnome.Mutter.RemoteDesktop.Session",
|
||||
)
|
||||
.await?;
|
||||
let session_id: String = rd_session
|
||||
.get_property("SessionId")
|
||||
.await
|
||||
.context("read SessionId")?;
|
||||
|
||||
let sc = zbus::Proxy::new(
|
||||
&conn,
|
||||
BUS_SC,
|
||||
"/org/gnome/Mutter/ScreenCast",
|
||||
"org.gnome.Mutter.ScreenCast",
|
||||
)
|
||||
.await
|
||||
.context("ScreenCast proxy")?;
|
||||
let mut props: HashMap<&str, Value> = HashMap::new();
|
||||
props.insert("remote-desktop-session-id", Value::from(session_id));
|
||||
let sc_path: OwnedObjectPath = sc
|
||||
.call("CreateSession", &(props,))
|
||||
.await
|
||||
.context("ScreenCast.CreateSession")?;
|
||||
let sc_session = zbus::Proxy::new(
|
||||
&conn,
|
||||
BUS_SC,
|
||||
sc_path,
|
||||
"org.gnome.Mutter.ScreenCast.Session",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// `RecordMonitor(connector, properties) -> stream_path`. Only `cursor-mode` is set: the mode
|
||||
// belongs to the monitor's owner, not to us.
|
||||
let mut rec: HashMap<&str, Value> = HashMap::new();
|
||||
rec.insert(
|
||||
"cursor-mode",
|
||||
Value::from(if hw_cursor {
|
||||
CURSOR_METADATA
|
||||
} else {
|
||||
CURSOR_EMBEDDED
|
||||
}),
|
||||
);
|
||||
let stream_path: OwnedObjectPath = sc_session
|
||||
.call("RecordMonitor", &(connector, rec))
|
||||
.await
|
||||
.with_context(|| format!("Session.RecordMonitor({connector:?})"))?;
|
||||
let stream = zbus::Proxy::new(
|
||||
&conn,
|
||||
BUS_SC,
|
||||
stream_path,
|
||||
"org.gnome.Mutter.ScreenCast.Stream",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut added = stream
|
||||
.receive_signal("PipeWireStreamAdded")
|
||||
.await
|
||||
.context("subscribe PipeWireStreamAdded")?;
|
||||
rd_session
|
||||
.call_method("Start", &())
|
||||
.await
|
||||
.context("RemoteDesktop.Session.Start")?;
|
||||
let msg = tokio::time::timeout(Duration::from_secs(10), added.next())
|
||||
.await
|
||||
.map_err(|_| anyhow!("PipeWireStreamAdded did not arrive within 10s"))?
|
||||
.ok_or_else(|| anyhow!("signal stream ended before PipeWireStreamAdded"))?;
|
||||
let (node_id,): (u32,) = msg
|
||||
.body()
|
||||
.deserialize()
|
||||
.context("PipeWireStreamAdded body")?;
|
||||
tracing::info!(connector, node_id, "mutter: recording an existing monitor");
|
||||
|
||||
Ok(MutterSession {
|
||||
rd_session,
|
||||
_sc_session: sc_session,
|
||||
_conn: conn,
|
||||
node_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// The live session objects (held for the stream's lifetime) + the PipeWire node id.
|
||||
struct MutterSession {
|
||||
rd_session: zbus::Proxy<'static>,
|
||||
@@ -811,6 +995,56 @@ fn logical_scale(state: &CurrentState, connector: &str) -> Option<f64> {
|
||||
.map(|l| l.2)
|
||||
}
|
||||
|
||||
/// Every head Mutter reports, for [`crate::monitors::list`].
|
||||
///
|
||||
/// A pure `GetCurrentState` read on its own short-lived connection + runtime — no session, no
|
||||
/// `ApplyMonitorsConfig`, so it never touches the topology and never contends [`TOPOLOGY_LOCK`].
|
||||
/// Geometry comes from the **logical** monitors (`state.2`), which is the coordinate space that
|
||||
/// matters (see `crate::monitors`); a monitor absent from every logical monitor is disabled, and is
|
||||
/// reported as such at the origin rather than dropped.
|
||||
pub(crate) fn list_monitors() -> Result<Vec<crate::monitors::PhysicalMonitor>> {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("build tokio runtime (monitor enumeration)")?;
|
||||
let state = rt.block_on(async {
|
||||
let dc = display_config().await?;
|
||||
get_state(&dc).await
|
||||
})?;
|
||||
let mut out: Vec<_> = state
|
||||
.1
|
||||
.iter()
|
||||
.map(|(spec, _modes, _props)| {
|
||||
let (connector, vendor, product, _serial) = spec;
|
||||
// The logical monitor carrying this connector: its (x, y), scale and primary flag.
|
||||
let logical = state
|
||||
.2
|
||||
.iter()
|
||||
.find(|l| l.5.iter().any(|s| &s.0 == connector));
|
||||
let (w, h, refresh) = current_mode_full(&state, connector)
|
||||
.map(|(_id, w, h, hz)| (w.max(0) as u32, h.max(0) as u32, (hz * 1000.0) as u32))
|
||||
.unwrap_or((0, 0, 0));
|
||||
crate::monitors::PhysicalMonitor {
|
||||
connector: connector.clone(),
|
||||
description: crate::monitors::describe(vendor, product, connector),
|
||||
width: w,
|
||||
height: h,
|
||||
refresh_mhz: refresh,
|
||||
x: logical.map(|l| l.0).unwrap_or(0),
|
||||
y: logical.map(|l| l.1).unwrap_or(0),
|
||||
scale: logical.map(|l| l.2).filter(|s| *s > 0.0).unwrap_or(1.0),
|
||||
primary: logical.map(|l| l.4).unwrap_or(false),
|
||||
enabled: logical.is_some(),
|
||||
// Mutter names a `RecordVirtual` monitor indistinguishably from a physical one —
|
||||
// no prefix to key on, and the connector is minted per session. See the field doc.
|
||||
managed: false,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|m| (m.x, m.y, m.connector.clone()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read the virtual output's current scale and, when the user changed it (GNOME Settings
|
||||
/// mid-stream), persist it under the client's `scale_key` so the next connect reapplies it.
|
||||
/// Best-effort: read failures (teardown races, shell restart) are silently skipped.
|
||||
|
||||
@@ -111,25 +111,13 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
swaymsg(&["output", &name, "enable"])
|
||||
.with_context(|| format!("swaymsg output {name} enable"))?;
|
||||
|
||||
// Steer xdpw's headless output chooser at our new output, then run the portal
|
||||
// handshake on its own thread (it parks to keep the cast alive, like the other backends).
|
||||
ensure_xdpw_config()?;
|
||||
let chooser = chooser_file();
|
||||
std::fs::write(&chooser, format!("Monitor: {name}\n"))
|
||||
.with_context(|| format!("write {chooser}"))?;
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
let hw_cursor = self.hw_cursor;
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-wlr-vout".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
|
||||
.context("spawn wlroots portal thread")?;
|
||||
|
||||
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {name} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {name}"),
|
||||
// Steer xdpw's headless output chooser at our new output, then run the portal handshake on
|
||||
// its own thread (it parks to keep the cast alive, like the other backends). Serialized:
|
||||
// the chooser is one per-user file, so a concurrent session's write between ours and xdpw's
|
||||
// read would silently capture the wrong output (see `SELECTION_LOCK`).
|
||||
let (fd, node_id, stop) = {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
select_and_cast(&name, self.hw_cursor)?
|
||||
};
|
||||
tracing::info!(
|
||||
node_id,
|
||||
@@ -207,20 +195,29 @@ fn swaymsg(args: &[&str]) -> Result<String> {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Current output names from `swaymsg -t get_outputs` (JSON).
|
||||
fn output_names() -> Result<Vec<String>> {
|
||||
/// Run a swaymsg **query** (`-t <kind> --raw`) and parse its JSON.
|
||||
///
|
||||
/// ⚠️ Deliberately NOT [`swaymsg`]: that helper inserts `--` so its arguments are read as a sway
|
||||
/// *command*, which is right for `create_output` and wrong for a query — `-t` after `--` comes back
|
||||
/// as `Unknown/invalid command '-t'` (caught on-glass writing the monitor enumeration).
|
||||
fn swaymsg_query(kind: &str) -> Result<serde_json::Value> {
|
||||
let out = Command::new("swaymsg")
|
||||
.args(["-t", "get_outputs", "--raw"])
|
||||
.args(["-t", kind, "--raw"])
|
||||
.output()
|
||||
.context("run swaymsg (is sway installed?)")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"swaymsg -t get_outputs failed: {}",
|
||||
"swaymsg -t {kind} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
let raw = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
let outputs: serde_json::Value = serde_json::from_str(&raw).context("parse get_outputs")?;
|
||||
serde_json::from_str(&raw).with_context(|| format!("parse {kind}"))
|
||||
}
|
||||
|
||||
/// Current output names from `swaymsg -t get_outputs` (JSON).
|
||||
fn output_names() -> Result<Vec<String>> {
|
||||
let outputs = swaymsg_query("get_outputs")?;
|
||||
Ok(outputs
|
||||
.as_array()
|
||||
.context("get_outputs: not an array")?
|
||||
@@ -229,6 +226,114 @@ fn output_names() -> Result<Vec<String>> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Serializes **write-the-chooser → complete-the-handshake**, process-wide.
|
||||
///
|
||||
/// The chooser is a single per-user file: whoever writes last before xdpw reads wins. Two sessions
|
||||
/// starting at once (or a mirror starting beside a virtual output) would otherwise race, and the
|
||||
/// loser doesn't fail — it silently captures the *other* session's output. Held across the portal
|
||||
/// handshake, not just the write, because the read happens inside it.
|
||||
static SELECTION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Point xdpw's chooser at `output` and run the ScreenCast handshake, returning the portal fd +
|
||||
/// node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
|
||||
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc<AtomicBool>)> {
|
||||
ensure_xdpw_config()?;
|
||||
let chooser = chooser_file();
|
||||
std::fs::write(&chooser, format!("Monitor: {output}\n"))
|
||||
.with_context(|| format!("write {chooser}"))?;
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-wlr-cast".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
|
||||
.context("spawn wlroots portal thread")?;
|
||||
match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok((fd, node_id))) => Ok((fd, node_id, stop)),
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an **existing** sway output — the monitor-mirror path
|
||||
/// (`design/per-monitor-portal-capture.md` L3). Same chooser mechanism the virtual-output path
|
||||
/// uses, pointed at a physical connector instead of a headless one we created, so it inherits the
|
||||
/// "no GUI picker" property a background service needs.
|
||||
///
|
||||
/// The keepalive stops the cast and nothing else: sway keeps the monitor, because we never made it.
|
||||
pub(crate) fn stream_existing_output(
|
||||
connector: &str,
|
||||
hw_cursor: bool,
|
||||
) -> Result<crate::mirror::MirrorStream> {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?;
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
keepalive: Box::new(StopGuard(stop)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Every head sway reports, for [`crate::monitors::list`].
|
||||
///
|
||||
/// `swaymsg -t get_outputs` reports `rect` in the logical coordinate space (post-scale,
|
||||
/// post-transform) — what `crate::monitors` documents. An inactive output has no `current_mode`, so
|
||||
/// its mode reads as zeros rather than a guess.
|
||||
pub(crate) fn list_monitors() -> Result<Vec<crate::monitors::PhysicalMonitor>> {
|
||||
let parsed = swaymsg_query("get_outputs")?;
|
||||
let mut out: Vec<_> = parsed
|
||||
.as_array()
|
||||
.context("get_outputs: not an array")?
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
let connector = o.get("name")?.as_str()?.to_string();
|
||||
let rect = |k: &str| {
|
||||
o.get("rect")
|
||||
.and_then(|r| r.get(k))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let mode = |k: &str| {
|
||||
o.get("current_mode")
|
||||
.and_then(|m| m.get(k))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let str_field = |k: &str| o.get(k).and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||
Some(crate::monitors::PhysicalMonitor {
|
||||
description: crate::monitors::describe(
|
||||
str_field("make"),
|
||||
str_field("model"),
|
||||
&connector,
|
||||
),
|
||||
width: mode("width").max(0) as u32,
|
||||
height: mode("height").max(0) as u32,
|
||||
// sway reports `refresh` in mHz already.
|
||||
refresh_mhz: mode("refresh").max(0) as u32,
|
||||
x: rect("x") as i32,
|
||||
y: rect("y") as i32,
|
||||
scale: o
|
||||
.get("scale")
|
||||
.and_then(|v| v.as_f64())
|
||||
.filter(|s| *s > 0.0)
|
||||
.unwrap_or(1.0),
|
||||
primary: o
|
||||
.get("primary")
|
||||
.and_then(|v| v.as_bool())
|
||||
.or_else(|| o.get("focused").and_then(|v| v.as_bool()))
|
||||
.unwrap_or(false),
|
||||
enabled: o.get("active").and_then(|v| v.as_bool()).unwrap_or(true),
|
||||
// Sway auto-names headless outputs `HEADLESS-N` and that is what `create` adds. A
|
||||
// sway started with its own headless output would match too — hence best-effort.
|
||||
managed: connector.starts_with("HEADLESS-"),
|
||||
connector,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|m| (m.x, m.y, m.connector.clone()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Wait for the output `create_output` added (the name not in `before` — HEADLESS-N).
|
||||
fn wait_new_output(before: &[String], timeout: Duration) -> Result<String> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
//! The monitor-mirror display backend: stream a **physical** head the compositor already has,
|
||||
//! instead of creating a virtual one. See `design/per-monitor-portal-capture.md`.
|
||||
//!
|
||||
//! It implements [`VirtualDisplay`] so it drops into the session machinery unchanged — but it
|
||||
//! creates nothing, and that difference is load-bearing:
|
||||
//!
|
||||
//! * [`DisplayOwnership::External`] ("someone else's display, merely mirrored: no keep-alive, no
|
||||
//! topology, no reuse") is exactly the contract for a head we don't own, so the registry's
|
||||
//! pooling/linger/exclusive machinery passes it through. §7.1 of the design doc — never disable,
|
||||
//! re-position or "restore" a monitor the user is sitting in front of — holds *by construction*
|
||||
//! here rather than by everyone remembering it.
|
||||
//! * `create()` ignores the requested [`Mode`]. A panel runs at the mode the user set; the client
|
||||
//! scales. Reconfiguring someone's physical display to match a phone would be the same class of
|
||||
//! rudeness as blanking it (§7.3).
|
||||
//!
|
||||
//! Selection is a **host-level pin** (`PUNKTFUNK_CAPTURE_MONITOR`), not a per-session choice —
|
||||
//! the host-pinned decision of record (§5.3), which is also what makes the input anchor below
|
||||
//! sound: one pin for the whole host, so the shared injector can't be re-aimed per session.
|
||||
|
||||
use super::backend::{DisplayOwnership, VirtualDisplay, VirtualOutput};
|
||||
use super::monitors;
|
||||
use crate::{Compositor, Mode};
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
/// What a backend hands back when it starts recording an existing head.
|
||||
///
|
||||
/// `remote_fd` is the split: KWin and Mutter publish the node on the user's own PipeWire daemon
|
||||
/// (nothing to carry), while the portal-based backends (sway/xdpw, Hyprland/xdph) hand back a
|
||||
/// sandboxed remote fd that the capturer must connect through — the same distinction their
|
||||
/// *virtual*-output paths already make.
|
||||
pub(crate) struct MirrorStream {
|
||||
pub node_id: u32,
|
||||
pub remote_fd: Option<std::os::fd::OwnedFd>,
|
||||
/// Dropping this ends the recording. It never owns the monitor — we did not create it.
|
||||
pub keepalive: Box<dyn Send>,
|
||||
}
|
||||
|
||||
/// Streams an existing monitor, named by connector.
|
||||
pub struct MirrorDisplay {
|
||||
compositor: Compositor,
|
||||
connector: String,
|
||||
hw_cursor: bool,
|
||||
}
|
||||
|
||||
impl MirrorDisplay {
|
||||
pub fn new(compositor: Compositor, connector: String) -> Result<Self> {
|
||||
Ok(MirrorDisplay {
|
||||
compositor,
|
||||
connector,
|
||||
hw_cursor: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualDisplay for MirrorDisplay {
|
||||
fn name(&self) -> &'static str {
|
||||
"mirror"
|
||||
}
|
||||
|
||||
fn set_hw_cursor(&mut self, on: bool) {
|
||||
self.hw_cursor = on;
|
||||
}
|
||||
|
||||
fn hw_cursor(&self) -> bool {
|
||||
self.hw_cursor
|
||||
}
|
||||
|
||||
fn create(&mut self, _mode: Mode) -> Result<VirtualOutput> {
|
||||
// Resolve the pin against the live head list FIRST: it yields the geometry the input anchor
|
||||
// needs, and it turns "that monitor is gone" into one clear error before any compositor
|
||||
// call. `resolve` refuses to substitute a different head (see its doc).
|
||||
let monitors = monitors::list(self.compositor)
|
||||
.with_context(|| format!("enumerate monitors to mirror {:?}", self.connector))?;
|
||||
let target = monitors::resolve(&monitors, &self.connector)?;
|
||||
check_mirrorable(target, self.compositor)?;
|
||||
let origin = (target.x, target.y);
|
||||
let dims = (target.width, target.height, refresh_hz(target.refresh_mhz));
|
||||
|
||||
let stream = match self.compositor {
|
||||
#[cfg(target_os = "linux")]
|
||||
Compositor::Kwin => {
|
||||
crate::kwin::stream_existing_output(&target.connector, self.hw_cursor)?
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
Compositor::Mutter => {
|
||||
crate::mutter::stream_existing_output(&target.connector, self.hw_cursor)?
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
Compositor::Wlroots => {
|
||||
crate::wlroots::stream_existing_output(&target.connector, self.hw_cursor)?
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
Compositor::Hyprland => {
|
||||
crate::hyprland::stream_existing_output(&target.connector, self.hw_cursor)?
|
||||
}
|
||||
// gamescope is nested — it has no physical heads of its own, and `monitors::list`
|
||||
// already returned an empty set, so `resolve` failed before we got here. This arm
|
||||
// exists for exhaustiveness, not as a reachable path.
|
||||
other => bail!(
|
||||
"mirroring an existing monitor is not supported on the {} backend",
|
||||
other.id()
|
||||
),
|
||||
};
|
||||
|
||||
// NOTE: aiming absolute input at this head is the HOST's job, not ours — this crate must
|
||||
// not depend on pf-inject (see the crate doc: "never on capture/inject"). The host sets the
|
||||
// anchor from the same pin at startup; §7.2 of the design doc explains why it is host-level
|
||||
// rather than set here per session.
|
||||
tracing::info!(
|
||||
connector = %target.connector,
|
||||
mode = %target.mode_label(),
|
||||
at = %format!("+{}+{}", origin.0, origin.1),
|
||||
node_id = stream.node_id,
|
||||
"mirroring an existing monitor (no virtual display created)"
|
||||
);
|
||||
|
||||
// The keepalive IS the recording: dropping it stops the cast and leaves the monitor exactly
|
||||
// as it was (we created nothing, so there is nothing to restore).
|
||||
let mut out = VirtualOutput::owned(stream.node_id, Some(dims), stream.keepalive);
|
||||
// Portal-based backends (sway/xdpw, Hyprland/xdph) publish on a sandboxed remote the
|
||||
// capturer must connect through; KWin/Mutter use the user's own daemon.
|
||||
out.remote_fd = stream.remote_fd;
|
||||
// Never pooled, never lingered, never made primary/exclusive: we don't own this head.
|
||||
out.ownership = DisplayOwnership::External;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Can this head be mirrored at all? Each rejection is something a compositor would otherwise
|
||||
/// answer with silence or a black stream, so it is caught here where the reason can be stated.
|
||||
fn check_mirrorable(target: &monitors::PhysicalMonitor, compositor: Compositor) -> Result<()> {
|
||||
if !target.enabled {
|
||||
bail!(
|
||||
"monitor {:?} is disabled — enable it before streaming it",
|
||||
target.connector
|
||||
);
|
||||
}
|
||||
if target.managed {
|
||||
// `managed` is only *conclusive* where the name is ours by construction: KWin's
|
||||
// `Virtual-punktfunk` prefix, Hyprland's `PF-N`. Sway names EVERY headless output
|
||||
// `HEADLESS-N` — its own included — so refusing there would block a legitimate setup
|
||||
// (a headless sway box whose outputs are all HEADLESS-N is exactly the remote case this
|
||||
// feature serves). Warn and continue; a user who really did pin our own virtual display
|
||||
// sees a mirror of it and the log says why.
|
||||
if names_ours_conclusively(compositor) {
|
||||
bail!(
|
||||
"monitor {:?} is one of punktfunk's own virtual displays, not a physical head — \
|
||||
clear the streamed-screen setting to use the normal virtual-display path",
|
||||
target.connector
|
||||
);
|
||||
}
|
||||
tracing::warn!(
|
||||
connector = %target.connector,
|
||||
"the pinned monitor looks like a headless output — on this compositor that name is \
|
||||
ambiguous (it may be one of punktfunk's own virtual displays), mirroring it anyway"
|
||||
);
|
||||
}
|
||||
if target.width == 0 || target.height == 0 {
|
||||
bail!(
|
||||
"monitor {:?} reports no current mode ({}x{}) — it is not driving a signal",
|
||||
target.connector,
|
||||
target.width,
|
||||
target.height
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Does this compositor's `managed` flag mean "ours, for certain"? KWin outputs carry the
|
||||
/// `Virtual-punktfunk` prefix we chose, and Hyprland's are `PF-N` — both ours by construction.
|
||||
/// Sway's `HEADLESS-N` is sway's own generic naming, so it is a hint, not proof.
|
||||
fn names_ours_conclusively(compositor: Compositor) -> bool {
|
||||
matches!(compositor, Compositor::Kwin | Compositor::Hyprland)
|
||||
}
|
||||
|
||||
/// mHz → whole Hz for [`VirtualOutput::preferred_mode`], never 0 (the negotiation treats 0 as
|
||||
/// "unset", and a head that didn't report a refresh is still running at *some* rate — 60 is the
|
||||
/// same assumption KWin's own virtual outputs are born with).
|
||||
fn refresh_hz(mhz: u32) -> u32 {
|
||||
let hz = (mhz as f64 / 1000.0).round() as u32;
|
||||
if hz == 0 {
|
||||
60
|
||||
} else {
|
||||
hz
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn head(connector: &str) -> monitors::PhysicalMonitor {
|
||||
monitors::PhysicalMonitor {
|
||||
connector: connector.into(),
|
||||
description: "ACME 27".into(),
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
refresh_mhz: 144_000,
|
||||
x: 1920,
|
||||
y: 0,
|
||||
scale: 1.0,
|
||||
primary: false,
|
||||
enabled: true,
|
||||
managed: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_enabled_head_is_mirrorable() {
|
||||
assert!(check_mirrorable(&head("DP-2"), Compositor::Kwin).is_ok());
|
||||
}
|
||||
|
||||
/// Streaming a dark head would be a black rectangle with no explanation — say why instead.
|
||||
#[test]
|
||||
fn a_disabled_head_is_refused_with_the_reason() {
|
||||
let mut m = head("DP-2");
|
||||
m.enabled = false;
|
||||
let err = check_mirrorable(&m, Compositor::Kwin)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("disabled"), "{err}");
|
||||
}
|
||||
|
||||
/// Mirroring our OWN virtual display is a loop with extra steps; catch the misconfiguration
|
||||
/// rather than streaming something confusing.
|
||||
#[test]
|
||||
fn one_of_our_own_virtual_displays_is_refused() {
|
||||
let mut m = head("Virtual-punktfunk-1");
|
||||
m.managed = true;
|
||||
let err = check_mirrorable(&m, Compositor::Kwin)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("virtual displays"), "{err}");
|
||||
}
|
||||
|
||||
/// Sway names every headless output `HEADLESS-N`, its own included, so the `managed` hint is
|
||||
/// NOT proof there — refusing would block a headless sway box, which is exactly the remote
|
||||
/// setup this feature serves. (Found on-glass: a two-output headless sway had both heads
|
||||
/// flagged, and the KWin-strength rule would have refused to mirror either.)
|
||||
#[test]
|
||||
fn a_headless_sway_output_is_mirrored_despite_the_ambiguous_name() {
|
||||
let mut m = head("HEADLESS-2");
|
||||
m.managed = true;
|
||||
assert!(check_mirrorable(&m, Compositor::Wlroots).is_ok());
|
||||
// Hyprland's `PF-N` IS our naming, so it stays conclusive.
|
||||
assert!(check_mirrorable(&m, Compositor::Hyprland).is_err());
|
||||
}
|
||||
|
||||
/// A head listed but not driving a mode (enabled yet modeless) would negotiate a 0x0 stream.
|
||||
#[test]
|
||||
fn a_head_with_no_current_mode_is_refused() {
|
||||
let mut m = head("DP-2");
|
||||
m.width = 0;
|
||||
m.height = 0;
|
||||
let err = check_mirrorable(&m, Compositor::Kwin)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("no current mode"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_rounds_and_never_reports_zero() {
|
||||
assert_eq!(refresh_hz(60000), 60);
|
||||
assert_eq!(refresh_hz(59940), 60);
|
||||
assert_eq!(refresh_hz(119_920), 120);
|
||||
// An unreported refresh must not become a 0 Hz preferred mode.
|
||||
assert_eq!(refresh_hz(0), 60);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Physical-monitor enumeration — "what heads does this host actually have?".
|
||||
//!
|
||||
//! This is the read-only counterpart to the rest of this crate: everything else here *creates* and
|
||||
//! owns virtual outputs, while this module only *reports* the heads the compositor already has, so
|
||||
//! an operator can pin capture at one of them (`PUNKTFUNK_CAPTURE_MONITOR`) and the console can
|
||||
//! render a picker. See `design/per-monitor-portal-capture.md` §5.1.
|
||||
//!
|
||||
//! It lives in pf-vdisplay because monitor enumeration is a **per-compositor** question and this
|
||||
//! crate already speaks every one of those dialects — KWin's `kde_output_device_v2`, Mutter's
|
||||
//! `DisplayConfig.GetCurrentState`, `swaymsg -t get_outputs`, `hyprctl -j monitors`. Each backend's
|
||||
//! implementation sits beside the code that already talks to it; this module is the shared type and
|
||||
//! the dispatch.
|
||||
//!
|
||||
//! **The geometry is the point.** `x`/`y` are what make a head *identifiable*: two monitors can
|
||||
//! share a size (and then a size-keyed match is a coin flip — see `pf-inject`'s absolute-coordinate
|
||||
//! region selection), but they can never share an origin in the compositor's global space.
|
||||
|
||||
use crate::Compositor;
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
/// One head as the compositor currently reports it. Logical (post-scale) geometry throughout —
|
||||
/// the same coordinate space libei regions and compositor layout use, *not* pixels.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PhysicalMonitor {
|
||||
/// Connector name — `DP-1`, `HDMI-A-2`, `eDP-1`. The id `PUNKTFUNK_CAPTURE_MONITOR` names.
|
||||
pub connector: String,
|
||||
/// Human label for a picker (`make model`, else the connector). Never used for matching.
|
||||
pub description: String,
|
||||
/// Current mode, in pixels.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Refresh in mHz (60000 = 60 Hz). 0 when the backend doesn't report it.
|
||||
pub refresh_mhz: u32,
|
||||
/// Top-left in the compositor's global logical space — the identity key (see the module doc).
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
/// Logical scale factor (1.0 when unreported).
|
||||
pub scale: f64,
|
||||
/// The compositor's primary/focused head, when it says.
|
||||
pub primary: bool,
|
||||
/// Enabled (driven). A disabled head is still listed, so "why can't I pick it?" has an answer.
|
||||
pub enabled: bool,
|
||||
/// **Best-effort**: this output is one WE created (a managed virtual display), not a real head.
|
||||
/// Only KWin can say so reliably (managed outputs carry a name prefix); the other backends
|
||||
/// name virtual outputs indistinguishably from physical ones, so this stays `false` there
|
||||
/// rather than guessing. Callers use it to grey out nonsense choices, never to filter blindly.
|
||||
pub managed: bool,
|
||||
}
|
||||
|
||||
/// Build the picker label from a compositor's make/model, falling back to the connector.
|
||||
///
|
||||
/// Compositors fill unknown fields with the literal string `"Unknown"` rather than leaving them
|
||||
/// empty (seen on-glass: sway reports `"Unknown Unknown"` for a headless output), so treat that as
|
||||
/// absent too — a picker row reading "Unknown Unknown" is worse than one reading "DP-1".
|
||||
pub(crate) fn describe(make: &str, model: &str, connector: &str) -> String {
|
||||
let known = |s: &str| {
|
||||
let s = s.trim();
|
||||
!s.is_empty() && !s.eq_ignore_ascii_case("unknown")
|
||||
};
|
||||
let label = [make, model]
|
||||
.iter()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| known(s))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if label.is_empty() {
|
||||
connector.to_string()
|
||||
} else {
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
impl PhysicalMonitor {
|
||||
/// `1920x1080@60` — for logs and pickers.
|
||||
pub fn mode_label(&self) -> String {
|
||||
if self.refresh_mhz == 0 {
|
||||
format!("{}x{}", self.width, self.height)
|
||||
} else {
|
||||
format!(
|
||||
"{}x{}@{}",
|
||||
self.width,
|
||||
self.height,
|
||||
(self.refresh_mhz as f64 / 1000.0).round() as u32
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every head `compositor` reports, in the compositor's own order.
|
||||
///
|
||||
/// Errors when the backend can't be reached (compositor not running, IPC unavailable) — that is
|
||||
/// deliberately distinct from `Ok(vec![])`, which means "reached it, it has no heads" (a headless
|
||||
/// session). Callers that only want to *offer* a picker can treat both as "nothing to show";
|
||||
/// callers resolving a pinned monitor must not (see [`resolve`]).
|
||||
pub fn list(compositor: Compositor) -> Result<Vec<PhysicalMonitor>> {
|
||||
match compositor {
|
||||
#[cfg(target_os = "linux")]
|
||||
Compositor::Kwin => crate::kwin_output_mgmt::list_monitors(),
|
||||
#[cfg(target_os = "linux")]
|
||||
Compositor::Mutter => crate::mutter::list_monitors(),
|
||||
#[cfg(target_os = "linux")]
|
||||
Compositor::Wlroots => crate::wlroots::list_monitors(),
|
||||
#[cfg(target_os = "linux")]
|
||||
Compositor::Hyprland => crate::hyprland::list_monitors(),
|
||||
// gamescope is nested: it has no physical heads of its own, and mirroring the desktop it
|
||||
// runs on is a question for the compositor UNDER it. Not an error — just nothing to offer.
|
||||
#[cfg(target_os = "linux")]
|
||||
Compositor::Gamescope => Ok(Vec::new()),
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
_ => bail!("physical-monitor enumeration is implemented for the Linux backends only"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a configured monitor name against `monitors`, exactly then case-insensitively.
|
||||
///
|
||||
/// **A miss is a hard error carrying the available names**, never a silent fall-back to some other
|
||||
/// head: an operator who pinned `DP-2` and gets `DP-1` streamed has been shown the wrong screen,
|
||||
/// which is worse than a host that refuses to start a session and says why
|
||||
/// (`design/per-monitor-portal-capture.md` §5.2).
|
||||
pub fn resolve<'a>(monitors: &'a [PhysicalMonitor], want: &str) -> Result<&'a PhysicalMonitor> {
|
||||
if let Some(m) = monitors.iter().find(|m| m.connector == want) {
|
||||
return Ok(m);
|
||||
}
|
||||
if let Some(m) = monitors
|
||||
.iter()
|
||||
.find(|m| m.connector.eq_ignore_ascii_case(want))
|
||||
{
|
||||
return Ok(m);
|
||||
}
|
||||
let available = monitors
|
||||
.iter()
|
||||
.map(|m| m.connector.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if available.is_empty() {
|
||||
bail!("no monitor named {want:?} — this compositor reports no monitors at all");
|
||||
}
|
||||
bail!("no monitor named {want:?} — this host has: {available}");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mon(connector: &str) -> PhysicalMonitor {
|
||||
PhysicalMonitor {
|
||||
connector: connector.into(),
|
||||
description: String::new(),
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_mhz: 60000,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1.0,
|
||||
primary: false,
|
||||
enabled: true,
|
||||
managed: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_matches_exactly_then_case_insensitively() {
|
||||
let ms = [mon("DP-1"), mon("HDMI-A-2")];
|
||||
assert_eq!(resolve(&ms, "DP-1").unwrap().connector, "DP-1");
|
||||
assert_eq!(resolve(&ms, "hdmi-a-2").unwrap().connector, "HDMI-A-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_the_exact_match_over_a_case_fold() {
|
||||
// Connector names are case-sensitive to the compositor; if both spellings exist, the
|
||||
// exact one wins rather than whichever the fold happened to reach first.
|
||||
let ms = [mon("dp-1"), mon("DP-1")];
|
||||
assert_eq!(resolve(&ms, "DP-1").unwrap().connector, "DP-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_miss_lists_what_is_available() {
|
||||
let ms = [mon("DP-1"), mon("HDMI-A-2")];
|
||||
let err = resolve(&ms, "DP-9").unwrap_err().to_string();
|
||||
assert!(err.contains("DP-1") && err.contains("HDMI-A-2"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_miss_with_no_monitors_says_so() {
|
||||
let err = resolve(&[], "DP-1").unwrap_err().to_string();
|
||||
assert!(err.contains("no monitors at all"), "{err}");
|
||||
}
|
||||
|
||||
/// Compositors write the literal "Unknown" instead of leaving make/model empty (sway does it
|
||||
/// for headless outputs), so a picker must not end up showing "Unknown Unknown".
|
||||
#[test]
|
||||
fn describe_falls_back_to_the_connector_for_empty_or_unknown_fields() {
|
||||
assert_eq!(describe("ACME", "U2720Q", "DP-1"), "ACME U2720Q");
|
||||
assert_eq!(describe("Unknown", "Unknown", "HEADLESS-1"), "HEADLESS-1");
|
||||
assert_eq!(describe("", "", "DP-1"), "DP-1");
|
||||
// A half-known pair keeps the half that means something.
|
||||
assert_eq!(describe("Unknown", "U2720Q", "DP-1"), "U2720Q");
|
||||
assert_eq!(describe(" ", "unknown", "DP-2"), "DP-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_label_drops_an_unknown_refresh() {
|
||||
let mut m = mon("DP-1");
|
||||
assert_eq!(m.mode_label(), "1920x1080@60");
|
||||
m.refresh_mhz = 0;
|
||||
assert_eq!(m.mode_label(), "1920x1080");
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,16 @@ pub struct DisplayPolicy {
|
||||
/// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.
|
||||
#[serde(default)]
|
||||
pub pnp_disable_monitors: bool,
|
||||
/// **Mirror a physical monitor instead of creating a virtual display**: the connector name
|
||||
/// (`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.
|
||||
///
|
||||
/// Orthogonal to `preset`/lifecycle (like `game_session`): a preset change never clears it, and
|
||||
/// `#[serde(default)]` leaves existing `display-settings.json` files untouched. It is a
|
||||
/// **host-wide** setting, not per-client — the host-pinned decision of record in
|
||||
/// `design/per-monitor-portal-capture.md` §5.3. `PUNKTFUNK_CAPTURE_MONITOR` overrides it (see
|
||||
/// [`capture_monitor`]), so an appliance can pin in `host.env` without the console fighting it.
|
||||
#[serde(default)]
|
||||
pub capture_monitor: Option<String>,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
@@ -271,6 +281,7 @@ impl Default for DisplayPolicy {
|
||||
game_session: GameSession::default(),
|
||||
ddc_power_off: false,
|
||||
pnp_disable_monitors: false,
|
||||
capture_monitor: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +327,12 @@ impl DisplayPolicy {
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
self.version = 1;
|
||||
self.max_displays = self.max_displays.clamp(1, 16);
|
||||
// A picker that clears its selection sends `""`; that means "no pin", not "match the
|
||||
// monitor named empty string" — same normalization the env knob does.
|
||||
self.capture_monitor = self
|
||||
.capture_monitor
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -332,6 +349,7 @@ impl EffectivePolicy {
|
||||
game_session: GameSession,
|
||||
ddc_power_off: bool,
|
||||
pnp_disable_monitors: bool,
|
||||
capture_monitor: Option<String>,
|
||||
) -> DisplayPolicy {
|
||||
DisplayPolicy {
|
||||
version: 1,
|
||||
@@ -345,10 +363,13 @@ impl EffectivePolicy {
|
||||
positions,
|
||||
},
|
||||
max_displays: self.max_displays,
|
||||
// Preserve the orthogonal axes (EffectivePolicy doesn't carry them).
|
||||
// Preserve the orthogonal axes (EffectivePolicy doesn't carry them). Dropping any of
|
||||
// them here would mean "saving a display arrangement silently cleared my setting" —
|
||||
// for `capture_monitor` that would swap the streamed screen out from under the operator.
|
||||
game_session,
|
||||
ddc_power_off,
|
||||
pnp_disable_monitors,
|
||||
capture_monitor,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -791,12 +812,19 @@ mod tests {
|
||||
let mut positions = BTreeMap::new();
|
||||
positions.insert("1".to_string(), Position { x: 0, y: 0 });
|
||||
positions.insert("7".to_string(), Position { x: 2560, y: 0 });
|
||||
let p = eff.with_manual_layout(positions, GameSession::Dedicated, true, true);
|
||||
// The orthogonal axes (game-session, DDC power-off, PnP disable) are preserved through
|
||||
// the transform.
|
||||
let p = eff.with_manual_layout(
|
||||
positions,
|
||||
GameSession::Dedicated,
|
||||
true,
|
||||
true,
|
||||
Some("DP-2".into()),
|
||||
);
|
||||
// The orthogonal axes (game-session, DDC power-off, PnP disable, capture-monitor pin) are
|
||||
// preserved through the transform — arranging displays must not clear an unrelated setting.
|
||||
assert_eq!(p.game_session, GameSession::Dedicated);
|
||||
assert!(p.ddc_power_off);
|
||||
assert!(p.pnp_disable_monitors);
|
||||
assert_eq!(p.capture_monitor.as_deref(), Some("DP-2"));
|
||||
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
|
||||
assert_eq!(p.preset, Preset::Custom);
|
||||
// …every other behavior axis is preserved verbatim…
|
||||
|
||||
@@ -268,6 +268,18 @@ pub fn restore_takeover_on_startup() {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn restore_takeover_on_startup() {}
|
||||
|
||||
/// Give the box its own session back **now**, synchronously, because the host is exiting. Blocks
|
||||
/// (it shells out to `systemctl`), so call it off the async runtime. Call from the host's shutdown
|
||||
/// path — a takeover that outlives the host leaves the box with no display manager and nobody left
|
||||
/// to restart it. No-op when nothing was taken over.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn restore_takeover_now() {
|
||||
gamescope::restore_takeover_now();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn restore_takeover_now() {}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -163,8 +163,15 @@ pub struct SessionEnv {
|
||||
/// `HYPRLAND_INSTANCE_SIGNATURE` of the live Hyprland instance (`Some` only for
|
||||
/// [`ActiveKind::DesktopHyprland`]). `hyprctl` needs it to reach the right instance socket;
|
||||
/// [`apply_session_env`] exports it so the systemd-`--user` host works without inheriting the
|
||||
/// session env (unlike sway's `SWAYSOCK`). `None` for every other compositor.
|
||||
/// session env. `None` for every other compositor.
|
||||
pub hyprland_signature: Option<String>,
|
||||
/// `SWAYSOCK` of the live sway instance (`Some` only for a sway [`ActiveKind::DesktopWlroots`]).
|
||||
/// `swaymsg` needs it, and it was the LAST session variable the host could not derive: a
|
||||
/// `systemd --user` host that never inherited the login shell's environment had no sway IPC at
|
||||
/// all, so output enumeration and the chooser both failed. Derived from the detected compositor
|
||||
/// PID like the Hyprland signature above. `None` on river (wlroots, but no sway IPC) and every
|
||||
/// other compositor.
|
||||
pub sway_socket: Option<String>,
|
||||
}
|
||||
|
||||
/// The live session: its [`ActiveKind`] plus the [`SessionEnv`] to target it.
|
||||
@@ -316,6 +323,12 @@ pub fn detect_active_session() -> ActiveSession {
|
||||
ActiveKind::DesktopHyprland => find_hypr_signature(&xdg_runtime_dir, uid),
|
||||
_ => None,
|
||||
};
|
||||
// Same idea for sway's IPC socket: without it `swaymsg` has nothing to talk to, and a
|
||||
// `systemd --user` host never inherited it.
|
||||
let sway_socket = match kind {
|
||||
ActiveKind::DesktopWlroots => find_sway_socket(&xdg_runtime_dir, uid, winning_pid),
|
||||
_ => None,
|
||||
};
|
||||
ActiveSession {
|
||||
kind,
|
||||
env: SessionEnv {
|
||||
@@ -324,6 +337,7 @@ pub fn detect_active_session() -> ActiveSession {
|
||||
dbus_session_bus_address: dbus,
|
||||
xdg_current_desktop,
|
||||
hyprland_signature,
|
||||
sway_socket,
|
||||
},
|
||||
compositor_pid: winning_pid,
|
||||
}
|
||||
@@ -361,6 +375,46 @@ fn find_hypr_signature(runtime: &str, uid: u32) -> Option<String> {
|
||||
cands.into_iter().next().map(|(_, n)| n)
|
||||
}
|
||||
|
||||
/// Find the live sway IPC socket (`SWAYSOCK`) for our uid. Trust a valid inherited value first (the
|
||||
/// host launched inside the session); then the exact `sway-ipc.<uid>.<pid>.sock` for the compositor
|
||||
/// PID detection already picked — a name sway builds from those two numbers, so it is an identity
|
||||
/// match rather than a guess; then the newest-mtime `sway-ipc.<uid>.*.sock` we own, for the case
|
||||
/// where the socket name does not match the PID we saw (sway re-exec, a wrapper process).
|
||||
///
|
||||
/// `None` on river: it is the other [`ActiveKind::DesktopWlroots`] compositor and has no sway IPC —
|
||||
/// which is the honest answer, since the wlroots backend drives sway through `swaymsg`.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn find_sway_socket(runtime: &str, uid: u32, pid: Option<u32>) -> Option<String> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if let Ok(s) = std::env::var("SWAYSOCK") {
|
||||
if !s.is_empty() && std::path::Path::new(&s).exists() {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
if let Some(pid) = pid {
|
||||
let exact = std::path::Path::new(runtime).join(format!("sway-ipc.{uid}.{pid}.sock"));
|
||||
if exact.exists() {
|
||||
return Some(exact.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
let prefix = format!("sway-ipc.{uid}.");
|
||||
let mut cands: Vec<(std::time::SystemTime, String)> = Vec::new();
|
||||
for e in std::fs::read_dir(runtime).ok()?.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if !name.starts_with(&prefix) || !name.ends_with(".sock") {
|
||||
continue;
|
||||
}
|
||||
let Ok(md) = e.metadata() else { continue };
|
||||
if md.uid() != uid {
|
||||
continue;
|
||||
}
|
||||
let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
|
||||
cands.push((mtime, e.path().to_string_lossy().into_owned()));
|
||||
}
|
||||
cands.sort_by_key(|(m, _)| std::cmp::Reverse(*m));
|
||||
cands.into_iter().next().map(|(_, p)| p)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn detect_active_session() -> ActiveSession {
|
||||
ActiveSession::none()
|
||||
@@ -426,6 +480,15 @@ pub fn apply_session_env(active: &ActiveSession) {
|
||||
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
|
||||
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
|
||||
}
|
||||
// sway: same treatment, and for the same reason — `swaymsg` (output enumeration, the capture
|
||||
// chooser) is unreachable without it, so a systemd `--user` host that never inherited the login
|
||||
// environment had no sway backend at all. Cleared when nothing sway-shaped is live, so a
|
||||
// sway→Hyprland switch can't leave `swaymsg` aimed at a dead socket. `wlroots::is_available()`
|
||||
// keys off this variable, so setting it here is also what makes the backend visible at all.
|
||||
match &e.sway_socket {
|
||||
Some(sock) => std::env::set_var("SWAYSOCK", sock),
|
||||
None => std::env::remove_var("SWAYSOCK"),
|
||||
}
|
||||
// NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous
|
||||
// connect's retarget, and the availability probes read them: after a gnome-shell crash
|
||||
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
|
||||
@@ -564,3 +627,98 @@ pub fn settle_desktop_portal(chosen: Compositor) {
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn settle_desktop_portal(_chosen: Compositor) {}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A scratch runtime dir with the sway-ipc sockets named in `pids`, plus the uid the names are
|
||||
/// built from. Removed on drop.
|
||||
struct FakeRuntime {
|
||||
dir: std::path::PathBuf,
|
||||
uid: u32,
|
||||
}
|
||||
|
||||
impl FakeRuntime {
|
||||
fn new(tag: &str, pids: &[u32]) -> FakeRuntime {
|
||||
// SAFETY: parameterless POSIX call, returns the calling process's uid; touches no memory.
|
||||
let uid = unsafe { libc::getuid() };
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("pf-swaysock-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for pid in pids {
|
||||
std::fs::write(dir.join(format!("sway-ipc.{uid}.{pid}.sock")), b"").unwrap();
|
||||
}
|
||||
FakeRuntime { dir, uid }
|
||||
}
|
||||
fn path(&self) -> &str {
|
||||
self.dir.to_str().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FakeRuntime {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with `SWAYSOCK` unset, so the "trust what we inherited" rung can't decide the test.
|
||||
/// Serialized on the crate's env lock — these tests mutate process-global state.
|
||||
fn without_inherited_swaysock<R>(f: impl FnOnce() -> R) -> R {
|
||||
crate::with_env_lock(|| {
|
||||
let prev = std::env::var_os("SWAYSOCK");
|
||||
std::env::remove_var("SWAYSOCK");
|
||||
let out = f();
|
||||
if let Some(p) = prev {
|
||||
std::env::set_var("SWAYSOCK", p);
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
/// The point of deriving it: the socket that belongs to the compositor detection actually found,
|
||||
/// not merely *a* sway socket. A stale socket from a previous sway (crash, re-login) sitting in
|
||||
/// the same runtime dir must not win.
|
||||
#[test]
|
||||
fn the_socket_matching_the_detected_pid_wins() {
|
||||
let rt = FakeRuntime::new("exact", &[4242, 9999]);
|
||||
let got = without_inherited_swaysock(|| find_sway_socket(rt.path(), rt.uid, Some(4242)));
|
||||
assert_eq!(
|
||||
got,
|
||||
Some(format!("{}/sway-ipc.{}.4242.sock", rt.path(), rt.uid))
|
||||
);
|
||||
}
|
||||
|
||||
/// sway re-exec (or a wrapper) can leave the socket named for a PID we didn't see. One socket in
|
||||
/// the dir is still unambiguous — better to hand `swaymsg` the real thing than nothing.
|
||||
#[test]
|
||||
fn an_unmatched_pid_falls_back_to_the_socket_that_is_there() {
|
||||
let rt = FakeRuntime::new("fallback", &[777]);
|
||||
let got = without_inherited_swaysock(|| find_sway_socket(rt.path(), rt.uid, Some(12345)));
|
||||
assert_eq!(
|
||||
got,
|
||||
Some(format!("{}/sway-ipc.{}.777.sock", rt.path(), rt.uid))
|
||||
);
|
||||
}
|
||||
|
||||
/// river is the other wlroots desktop and ships no sway IPC. Reporting `None` is what keeps
|
||||
/// `apply_session_env` from exporting a `SWAYSOCK` that points at nothing — an exported lie
|
||||
/// would make `wlroots::is_available()` claim a backend that cannot answer.
|
||||
#[test]
|
||||
fn no_sway_ipc_socket_reports_none() {
|
||||
let rt = FakeRuntime::new("none", &[]);
|
||||
let got = without_inherited_swaysock(|| find_sway_socket(rt.path(), rt.uid, Some(1)));
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// Someone else's socket in a shared runtime dir is not ours to talk to.
|
||||
#[test]
|
||||
fn another_uids_socket_is_ignored() {
|
||||
let rt = FakeRuntime::new("otheruid", &[]);
|
||||
let other = rt.uid.wrapping_add(1);
|
||||
std::fs::write(rt.dir.join(format!("sway-ipc.{other}.500.sock")), b"").unwrap();
|
||||
let got = without_inherited_swaysock(|| find_sway_socket(rt.path(), rt.uid, Some(500)));
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -865,11 +865,21 @@ impl VirtualDisplayManager {
|
||||
reasserts = fighting,
|
||||
"exclusive topology stable again — no non-managed display active"
|
||||
);
|
||||
// Close the churn window early — descriptor-following resumes now
|
||||
// instead of waiting out the hold's self-expiry.
|
||||
pf_win_display::topology_churn::release();
|
||||
}
|
||||
fighting = 0;
|
||||
continue;
|
||||
}
|
||||
fighting += 1;
|
||||
// Announce the churn BEFORE evicting: every descriptor the capturer's poller
|
||||
// samples from here until "stable again" (or self-expiry) is potentially the
|
||||
// TRANSIENT eviction state — acting on it would recreate the ring at a mode
|
||||
// the recovery chain is about to undo (the field hdr=true→false→true double
|
||||
// recreate). Window = watchdog interval + recovery/debounce margin, refreshed
|
||||
// every fighting round.
|
||||
pf_win_display::topology_churn::hold(interval + Duration::from_secs(3));
|
||||
match fighting {
|
||||
1..=3 => tracing::warn!(
|
||||
survivors,
|
||||
|
||||
@@ -156,15 +156,27 @@ pub fn summarize(events: &[DisplayEvent]) -> String {
|
||||
out.join(", ")
|
||||
}
|
||||
|
||||
/// The prime suspects for link-probe disturbances, from the cached inventory: external physical
|
||||
/// displays that are CONNECTED but not part of the desktop (standby TV / input-switched monitor).
|
||||
/// Rendered as `"<friendly> (<connector>)"`. Never blocks on the CCD lock.
|
||||
pub fn connected_inactive_externals() -> Vec<String> {
|
||||
/// The prime suspects for link-probe/dark-head disturbances, from the cached inventory: PHYSICAL
|
||||
/// displays — external connectors AND internal panels — that are CONNECTED but not part of the
|
||||
/// desktop. External = the classic standby TV / input-switched monitor; internal = the laptop
|
||||
/// panel the exclusive isolate deactivated, whose driver-level servicing produces the identical
|
||||
/// ~2 s metronome on hybrid laptops (field A/B 2026-07-27: reporter's Legion, exclusive
|
||||
/// 16.3 stalls/min vs primary 0 — the old external-only filter reported `none` there and steered
|
||||
/// the diagnosis AWAY from the real cause for hours). Virtual/indirect targets stay excluded
|
||||
/// (precision rule). Rendered as `"<friendly> (<connector>)"`. Never blocks on the CCD lock.
|
||||
pub fn connected_inactive_physicals() -> Vec<String> {
|
||||
let st = state().lock().unwrap();
|
||||
st.inventory
|
||||
.iter()
|
||||
.filter(|t| t.external_physical && !t.active)
|
||||
.map(|t| format!("{} ({})", t.friendly, t.tech))
|
||||
.filter(|t| (t.external_physical || t.internal_panel) && !t.active)
|
||||
.map(|t| {
|
||||
let name = if t.friendly.is_empty() && t.internal_panel {
|
||||
"laptop panel"
|
||||
} else {
|
||||
&t.friendly
|
||||
};
|
||||
format!("{} ({})", name, t.tech)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ pub mod display_events;
|
||||
mod input_desktop;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod monitor_devnode;
|
||||
/// Cross-crate "topology churn in flight" latch (pure std — no Windows surface, so unconditionally
|
||||
/// compiled and unit-tested on every platform).
|
||||
pub mod topology_churn;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod win_display;
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Cross-crate "topology churn in flight" latch (stall-immunity program): the exclusive-topology
|
||||
//! reassert watchdog (pf-vdisplay's manager) announces that it is evicting/restoring displays, and
|
||||
//! the IDD-push capturer's descriptor follower (pf-capture) defers acting on descriptor changes
|
||||
//! while the window is open.
|
||||
//!
|
||||
//! Why: the reassert's forced re-commit transiently bounces the virtual display's mode — the
|
||||
//! descriptor poller can sample the EVICTION state (field log 2026-07-27 10:30:44Z: `hdr=true` →
|
||||
//! `hdr=false` → recreate → recovery restores `hdr=true` → second recreate). Every descriptor
|
||||
//! sampled inside the window is potentially that transient, and acting on it recreates the ring at
|
||||
//! a mode the recovery chain is about to undo. The deliberate recovery rebuild
|
||||
//! (`recreate_ring_in_place`, keyed off the reassert generation) is NOT affected — only the
|
||||
//! passive descriptor-following is.
|
||||
//!
|
||||
//! Deadline semantics, not a flag: a `hold()` self-expires, so a holder that dies mid-churn (or a
|
||||
//! release lost to a teardown race) can never wedge descriptor-following off forever. `release()`
|
||||
//! just expires the deadline early on the watchdog's "stable again" observation.
|
||||
|
||||
use std::sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
OnceLock,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Deadline as milliseconds on the process-local [`clock`]; `0` = no hold.
|
||||
static HOLD_UNTIL_MS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Process-local monotonic epoch (an `Instant` cannot live in an atomic).
|
||||
fn clock() -> u64 {
|
||||
static EPOCH: OnceLock<Instant> = OnceLock::new();
|
||||
EPOCH.get_or_init(Instant::now).elapsed().as_millis() as u64
|
||||
}
|
||||
|
||||
/// Open (or extend) the churn window for `dur` from now. `fetch_max` so overlapping holders — a
|
||||
/// reassert round racing a slot transition — never shorten each other's window.
|
||||
pub fn hold(dur: Duration) {
|
||||
let until = clock().saturating_add(dur.as_millis() as u64);
|
||||
HOLD_UNTIL_MS.fetch_max(until, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Expire the window now (the watchdog observed a stable topology again).
|
||||
pub fn release() {
|
||||
HOLD_UNTIL_MS.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Is a churn window open? Cheap enough for a per-frame path (one atomic load + a monotonic read).
|
||||
#[must_use]
|
||||
pub fn held() -> bool {
|
||||
clock() < HOLD_UNTIL_MS.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The latch's contract end-to-end: closed at rest, open after `hold`, extended by the longer
|
||||
/// of two overlapping holds, closed by `release`, and self-expiring without one.
|
||||
#[test]
|
||||
fn hold_release_expire() {
|
||||
release();
|
||||
assert!(!held());
|
||||
hold(Duration::from_secs(60));
|
||||
assert!(held());
|
||||
// A shorter overlapping hold must not shorten the window.
|
||||
hold(Duration::from_millis(1));
|
||||
assert!(held());
|
||||
release();
|
||||
assert!(!held());
|
||||
// Self-expiry: a millisecond-scale hold lapses on its own.
|
||||
hold(Duration::from_millis(30));
|
||||
assert!(held());
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
assert!(!held());
|
||||
}
|
||||
}
|
||||
@@ -844,15 +844,21 @@ pub unsafe fn count_other_active(keep_target_ids: &[u32]) -> Option<u32> {
|
||||
}
|
||||
|
||||
/// One CONNECTED display target from a full (`QDC_ALL_PATHS`) CCD sweep — the disturbance-
|
||||
/// attribution inventory. `external_physical` is the load-bearing bit: a standby TV/monitor on a
|
||||
/// real connector is the prime suspect for the periodic link-probe stutter class, while internal
|
||||
/// panels and indirect/virtual targets (our own IDD included) are not.
|
||||
/// attribution inventory. `external_physical` and `internal_panel` are the load-bearing bits: a
|
||||
/// standby TV/monitor on a real connector is the classic suspect for the periodic link-probe
|
||||
/// stutter class, and a laptop panel the exclusive isolate DEACTIVATED is the hybrid-laptop
|
||||
/// variant of the same disturbance (field A/B 2026-07-27: dark-but-connected eDP head on the
|
||||
/// iGPU → ~2 s stall metronome; `topology: primary` → zero) — only indirect/virtual targets
|
||||
/// (our own IDD included) can never be suspects.
|
||||
pub struct TargetInventory {
|
||||
pub target_id: u32,
|
||||
/// Whether any active path drives this target (part of the desktop right now).
|
||||
pub active: bool,
|
||||
/// External physical connector (HDMI/DP/DVI/…): candidate for standby link-probe churn.
|
||||
pub external_physical: bool,
|
||||
/// Internal panel (eDP/LVDS/embedded): candidate for dark-head servicing churn when
|
||||
/// connected-but-inactive (the exclusive isolate on a laptop creates exactly that state).
|
||||
pub internal_panel: bool,
|
||||
/// Short connector label for logs (`"HDMI"`, `"DisplayPort"`, `"internal-panel"`, …).
|
||||
pub tech: &'static str,
|
||||
/// The monitor's friendly name (`"LG TV SSCR2"`); empty when the EDID carries none.
|
||||
@@ -953,6 +959,7 @@ pub unsafe fn target_inventory() -> Vec<TargetInventory> {
|
||||
target_id: t.id,
|
||||
active: active.contains(&key),
|
||||
external_physical,
|
||||
internal_panel: tech == "internal-panel",
|
||||
tech,
|
||||
friendly: utf16z_str(&req.monitorFriendlyDeviceName),
|
||||
monitor_device_path: utf16z_str(&req.monitorDevicePath),
|
||||
|
||||
@@ -52,8 +52,8 @@ pub(crate) struct Negotiated {
|
||||
/// Host clock minus client clock (ns); `0` = no skew handshake (old host / synced clocks).
|
||||
pub(crate) clock_offset_ns: i64,
|
||||
/// Min RTT of the connect-time skew handshake (ns); `None` = the host never answered —
|
||||
/// mid-stream re-syncs are pointless then and stay off. The re-sync acceptance guard
|
||||
/// compares each batch against this baseline ([`accept_resync`]).
|
||||
/// mid-stream re-syncs are pointless then and stay off. Seeds the re-sync admission
|
||||
/// guard's session-floor baseline ([`ResyncGuard`]).
|
||||
pub(crate) clock_rtt_ns: Option<u64>,
|
||||
/// Resolved encode bit depth: `8`, or `10` for a Main10 / HDR session.
|
||||
pub(crate) bit_depth: u8,
|
||||
|
||||
@@ -248,7 +248,16 @@ pub(crate) struct EncodeLatAcc {
|
||||
/// reference-chain break the loss counters never saw). A transient burst fills it briefly and drains on
|
||||
/// its own, so a clump never costs a keyframe.
|
||||
///
|
||||
/// **All-intra exception** ([`set_all_intra`], PyroWave): every AU is independently decodable, so
|
||||
/// the reference-chain reasoning above does not apply — a consumer that falls behind can skip
|
||||
/// straight to the newest queued AU with zero recovery cost (no keyframe round-trip, no corrupt
|
||||
/// dependents). [`pop`] then drains to the newest instead of returning the oldest, which caps any
|
||||
/// standing queue at ~1 frame structurally; the 2026-07 field report's 780M client otherwise
|
||||
/// ratcheted a genuine multi-frame backlog between the coarse jump-to-live thresholds.
|
||||
///
|
||||
/// [`clear`]: FrameChannel::clear
|
||||
/// [`set_all_intra`]: FrameChannel::set_all_intra
|
||||
/// [`pop`]: FrameChannel::pop
|
||||
pub(crate) struct FrameChannel {
|
||||
inner: Mutex<FrameQueue>,
|
||||
ready: Condvar,
|
||||
@@ -259,6 +268,11 @@ struct FrameQueue {
|
||||
/// Set when the pump exits so a blocked [`FrameChannel::pop`] reports the stream ended
|
||||
/// ([`PunktfunkError::Closed`]) rather than a spurious timeout (the old mpsc did this on sender drop).
|
||||
closed: bool,
|
||||
/// Every AU decodes independently (PyroWave): [`FrameChannel::pop`] drains to the newest.
|
||||
all_intra: bool,
|
||||
/// AUs skipped by the all-intra drain since the last [`FrameChannel::take_skipped`] — NOT
|
||||
/// losses (the wire delivered them); the pump surfaces them at debug on its report tick.
|
||||
skipped_total: u64,
|
||||
}
|
||||
|
||||
/// Outcome of [`FrameChannel::pop`] — mirrors the old `recv_timeout` results so `next_frame`'s
|
||||
@@ -275,11 +289,25 @@ impl FrameChannel {
|
||||
inner: Mutex::new(FrameQueue {
|
||||
q: VecDeque::new(),
|
||||
closed: false,
|
||||
all_intra: false,
|
||||
skipped_total: 0,
|
||||
}),
|
||||
ready: Condvar::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pump side, once at session start: mark the stream all-intra (every AU independently
|
||||
/// decodable) — [`Self::pop`] then drains to the newest queued AU instead of strict FIFO.
|
||||
pub(crate) fn set_all_intra(&self, all_intra: bool) {
|
||||
self.inner.lock().unwrap().all_intra = all_intra;
|
||||
}
|
||||
|
||||
/// Pump side: AUs skipped by the all-intra drain since the last call (reset on read).
|
||||
pub(crate) fn take_skipped(&self) -> u64 {
|
||||
let mut st = self.inner.lock().unwrap();
|
||||
std::mem::take(&mut st.skipped_total)
|
||||
}
|
||||
|
||||
/// Pump side: append a completed AU and wake a blocked consumer. Enforces the memory backstop
|
||||
/// ([`FRAME_QUEUE_HARD_CAP`]) by dropping the oldest (see its doc — a jump-to-live keyframe is
|
||||
/// already in flight by the time this can bite).
|
||||
@@ -312,12 +340,21 @@ impl FrameChannel {
|
||||
self.ready.notify_all();
|
||||
}
|
||||
|
||||
/// Consumer side: pop the oldest AU, waiting up to `timeout` for one to arrive.
|
||||
/// Consumer side: pop the oldest AU, waiting up to `timeout` for one to arrive. On an
|
||||
/// all-intra stream ([`Self::set_all_intra`]) a multi-deep queue drains to the NEWEST AU
|
||||
/// instead — the skipped ones are already superseded and decode independently, so showing
|
||||
/// them only adds latency.
|
||||
pub(crate) fn pop(&self, timeout: Duration) -> FramePop {
|
||||
let mut st = self.inner.lock().unwrap();
|
||||
if st.q.is_empty() && !st.closed {
|
||||
st = self.ready.wait_timeout(st, timeout).unwrap().0;
|
||||
}
|
||||
if st.all_intra && st.q.len() > 1 {
|
||||
st.skipped_total += (st.q.len() - 1) as u64;
|
||||
let newest = st.q.pop_back().expect("len > 1");
|
||||
st.q.clear();
|
||||
return FramePop::Frame(newest);
|
||||
}
|
||||
if let Some(f) = st.q.pop_front() {
|
||||
FramePop::Frame(f)
|
||||
} else if st.closed {
|
||||
@@ -364,6 +401,25 @@ mod frame_channel_tests {
|
||||
assert_eq!(ch.depth(), 0);
|
||||
}
|
||||
|
||||
/// The all-intra exception: a multi-deep queue drains to the NEWEST AU (every frame
|
||||
/// decodes independently — older queued ones are superseded), the skips are counted
|
||||
/// separately from losses, and a single-deep queue behaves exactly like FIFO.
|
||||
#[test]
|
||||
fn all_intra_drains_to_newest_and_counts_skips() {
|
||||
let ch = FrameChannel::new();
|
||||
ch.set_all_intra(true);
|
||||
for i in 1..=3 {
|
||||
ch.push(frame(i));
|
||||
}
|
||||
assert_eq!(popped(&ch), Some(3));
|
||||
assert_eq!(ch.depth(), 0);
|
||||
assert_eq!(ch.take_skipped(), 2);
|
||||
assert_eq!(ch.take_skipped(), 0); // reset on read
|
||||
ch.push(frame(4));
|
||||
assert_eq!(popped(&ch), Some(4)); // depth 1 = plain FIFO, no skip accounting
|
||||
assert_eq!(ch.take_skipped(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pop_times_out_not_closed() {
|
||||
let ch = FrameChannel::new();
|
||||
|
||||
@@ -11,8 +11,8 @@ use crate::abr::BitrateController;
|
||||
use crate::config::Role;
|
||||
use crate::packet::FLAG_PROBE;
|
||||
use crate::quic::{
|
||||
accept_resync, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho,
|
||||
ClockResync, Hello, LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe,
|
||||
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync, Hello,
|
||||
LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncAdmit, ResyncGuard,
|
||||
ResyncStep, SetBitrate, Start, Welcome,
|
||||
};
|
||||
use crate::session::Session;
|
||||
|
||||
@@ -52,6 +52,14 @@ impl ControlTask {
|
||||
// the read arm below; only when the host answered the connect-time handshake — an
|
||||
// old host would just eat the probes.
|
||||
let mut resync = ClockResync::new();
|
||||
let mut resync_guard = clock_rtt_ns.map(ResyncGuard::new);
|
||||
// Inter-round spacing: without it the whole 8-round batch completes inside one
|
||||
// ~6 ms video burst and every round samples the same congestion state — a batch
|
||||
// that starts mid-burst is then wholly congested and gets rejected. 7 ms staggers
|
||||
// the rounds across the ~16.7 ms frame cycle so the min-RTT round almost always
|
||||
// lands in a quiet inter-burst gap, even at PyroWave-class bitrates.
|
||||
const RESYNC_ROUND_SPACING: std::time::Duration = std::time::Duration::from_millis(7);
|
||||
let mut staged_round: Option<tokio::time::Instant> = None;
|
||||
let mut resync_tick = tokio::time::interval_at(
|
||||
tokio::time::Instant::now() + CLOCK_RESYNC_INTERVAL,
|
||||
CLOCK_RESYNC_INTERVAL,
|
||||
@@ -72,6 +80,7 @@ impl ControlTask {
|
||||
if clock_rtt_ns.is_none() {
|
||||
continue; // no connect-time handshake — host can't answer
|
||||
}
|
||||
staged_round = None; // a new batch abandons any staged round
|
||||
resync.begin(wall_clock_ns()).encode()
|
||||
}
|
||||
CtrlRequest::ClipControl(c) => c.encode(),
|
||||
@@ -83,11 +92,21 @@ impl ControlTask {
|
||||
}
|
||||
}
|
||||
_ = resync_tick.tick(), if clock_rtt_ns.is_some() => {
|
||||
staged_round = None; // a new batch abandons any staged round
|
||||
let probe = resync.begin(wall_clock_ns());
|
||||
if io::write_msg(&mut ctrl_send, &probe.encode()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = async { tokio::time::sleep_until(staged_round.unwrap()).await },
|
||||
if staged_round.is_some() => {
|
||||
staged_round = None;
|
||||
// Stamped at send time so the inter-round spacing stays out of the RTT.
|
||||
let probe = resync.next_probe(wall_clock_ns());
|
||||
if io::write_msg(&mut ctrl_send, &probe.encode()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
msg = ctrl_recv.read_msg() => {
|
||||
let Ok(msg) = msg else { break }; // stream closed
|
||||
if let Ok(ack) = Reconfigured::decode(&msg) {
|
||||
@@ -132,16 +151,36 @@ impl ControlTask {
|
||||
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
|
||||
} else if let Ok(echo) = ClockEcho::decode(&msg) {
|
||||
match resync.on_echo(&echo, wall_clock_ns()) {
|
||||
ResyncStep::Probe(p) => {
|
||||
if io::write_msg(&mut ctrl_send, &p.encode()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
ResyncStep::MoreRounds => {
|
||||
staged_round = Some(
|
||||
tokio::time::Instant::now() + RESYNC_ROUND_SPACING,
|
||||
);
|
||||
}
|
||||
ResyncStep::Done { offset_ns, rtt_ns } => {
|
||||
// Never let a congested window bias the offset (frames read
|
||||
// late exactly then) — keep the old estimate and let the next
|
||||
// periodic batch try again.
|
||||
if accept_resync(rtt_ns, clock_rtt_ns.unwrap_or(0)) {
|
||||
let Some(guard) = resync_guard.as_mut() else {
|
||||
continue; // no connect handshake — batches never start
|
||||
};
|
||||
let (apply, best_of_streak) = match guard.admit(offset_ns, rtt_ns)
|
||||
{
|
||||
ResyncAdmit::Fresh => (Some((offset_ns, rtt_ns)), false),
|
||||
ResyncAdmit::BestOfStreak { offset_ns, rtt_ns } => {
|
||||
(Some((offset_ns, rtt_ns)), true)
|
||||
}
|
||||
ResyncAdmit::Rejected { streak } => {
|
||||
// warn, not debug: repeated rejections are exactly the
|
||||
// stale-offset starvation signature the 2026-07
|
||||
// PyroWave-sawtooth report had to be diagnosed without.
|
||||
tracing::warn!(
|
||||
rtt_us = rtt_ns / 1000,
|
||||
floor_us = guard.floor_rtt_ns() / 1000,
|
||||
streak,
|
||||
"clock re-sync batch rejected — RTT above the \
|
||||
session floor (congested window)"
|
||||
);
|
||||
(None, false)
|
||||
}
|
||||
};
|
||||
if let Some((offset_ns, rtt_ns)) = apply {
|
||||
// info, not debug: ≤1/min, and it is THE forensic
|
||||
// trail for a stale-offset (stepped/slewed wall clock)
|
||||
// latency plateau — the 2026-07 two-pair investigation
|
||||
@@ -149,16 +188,11 @@ impl ControlTask {
|
||||
tracing::info!(
|
||||
offset_ns,
|
||||
rtt_us = rtt_ns / 1000,
|
||||
best_of_streak,
|
||||
"mid-stream clock re-sync applied"
|
||||
);
|
||||
clock_offset.store(offset_ns, Ordering::Relaxed);
|
||||
clock_gen.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
tracing::info!(
|
||||
rtt_us = rtt_ns / 1000,
|
||||
"clock re-sync batch discarded — RTT above the \
|
||||
connect-time baseline (congested window)"
|
||||
);
|
||||
}
|
||||
}
|
||||
ResyncStep::Idle => {}
|
||||
|
||||
@@ -87,6 +87,10 @@ impl DataPump {
|
||||
// above its floor, and the climb probe's VBV reasoning doesn't apply to hard
|
||||
// per-frame CBR — controller and capacity probe stay off (0 = permanently off).
|
||||
let rate_pinned = negotiated_codec == crate::quic::CODEC_PYROWAVE;
|
||||
// All-intra streams have no reference chains: the frame channel drains to the newest
|
||||
// AU instead of strict FIFO (see `FrameChannel::set_all_intra`), so a slow consumer
|
||||
// caps its standing queue at ~1 frame with zero recovery cost.
|
||||
frames.set_all_intra(negotiated_codec == crate::quic::CODEC_PYROWAVE);
|
||||
let mut abr = BitrateController::new(if bitrate_kbps == 0 && !rate_pinned {
|
||||
resolved_bitrate_kbps
|
||||
} else {
|
||||
@@ -114,6 +118,15 @@ impl DataPump {
|
||||
// in; the embedder path had neither, so an unanswered request wedged the report tick and a
|
||||
// finished one left the ABR window anchored before the burst.
|
||||
let mut was_probing = false;
|
||||
// Set when a probe ends: the FIRST post-probe report window is discarded outright (no
|
||||
// LossReport, no standing-latency close, no ABR feed). The `last_*` rebase below cannot
|
||||
// fully clean it — probe frames still pending in the reassembler age out as
|
||||
// `frames_dropped` for another LOSS_WINDOW (~120 ms) AFTER the rebase, and the burst may
|
||||
// have latched `flush_in_window` — and either reads as SEVERE congestion. The 2026-07
|
||||
// field report's Automatic session backed off 20→14 Mb/s one second in (exactly one
|
||||
// report tick after its capacity probe) and, with slow start dead from that first
|
||||
// "congestion", crawled additively for the entire match.
|
||||
let mut discard_abr_window = false;
|
||||
let mut probe_watchdog: Option<Instant> = None;
|
||||
let (mut owd_sum_ns, mut owd_frames) = (0i128, 0u32);
|
||||
let mut flush_in_window = false;
|
||||
@@ -193,6 +206,8 @@ impl DataPump {
|
||||
last_dropped = st.frames_dropped;
|
||||
last_bytes = st.bytes_received;
|
||||
last_report = Instant::now();
|
||||
discard_abr_window = true;
|
||||
flush_in_window = false;
|
||||
}
|
||||
// Arm a watchdog on the leading edge of ANY probe, so a host that silently ignores
|
||||
// `ProbeRequest` (an old build — anticipated, see the capacity-probe timeout below)
|
||||
@@ -289,6 +304,14 @@ impl DataPump {
|
||||
resync_wanted = false;
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::ClockResync);
|
||||
}
|
||||
// All-intra drain-to-newest skips are NOT losses (the wire delivered them) —
|
||||
// surface them at debug so a slow consumer is visible without alarming the
|
||||
// OSD loss counters.
|
||||
let skipped = frames.take_skipped();
|
||||
if skipped > 0 {
|
||||
tracing::debug!(skipped, "all-intra frame channel drained to newest");
|
||||
}
|
||||
let discard = std::mem::take(&mut discard_abr_window);
|
||||
let window_dropped = st.frames_dropped.wrapping_sub(last_dropped);
|
||||
let loss_ppm = window_loss_ppm(
|
||||
st.fec_recovered_shards.wrapping_sub(last_recovered),
|
||||
@@ -296,14 +319,26 @@ impl DataPump {
|
||||
st.packets_received.wrapping_sub(last_received),
|
||||
window_dropped,
|
||||
);
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
|
||||
if discard {
|
||||
// Probe-tail residue (see `discard_abr_window`): a LossReport from this
|
||||
// window would also spike the host's adaptive FEC off deliberate overload.
|
||||
tracing::debug!(
|
||||
loss_ppm,
|
||||
window_dropped,
|
||||
"discarding the first post-probe ABR window (probe-tail residue)"
|
||||
);
|
||||
} else {
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
|
||||
}
|
||||
// Standing-latency bleed: close the detector's window with this report's loss
|
||||
// verdict and run its escalation ladder — re-sync first (free; a stale offset
|
||||
// from a stepped wall clock produces exactly this signature and the applied
|
||||
// re-sync rebases the floor), then a bounded flush+keyframe (drains a real
|
||||
// sub-threshold standing backlog the jump-to-live thresholds tolerate), then a
|
||||
// loud disarm (the path latency itself changed; nothing local fixes that).
|
||||
match standing_lat.on_window(loss_ppm == 0 && window_dropped == 0) {
|
||||
// A discard window closes the detector as NOT-loss-free: its clean-run resets
|
||||
// (conservative) and no action can fire off probe residue.
|
||||
match standing_lat.on_window(!discard && loss_ppm == 0 && window_dropped == 0) {
|
||||
StandingLatAction::None => {}
|
||||
StandingLatAction::Resync { above_ms } => {
|
||||
tracing::info!(
|
||||
@@ -386,16 +421,23 @@ impl DataPump {
|
||||
let window_ms = last_report.elapsed().as_millis().max(1) as u64;
|
||||
let actual_kbps = (st.bytes_received.wrapping_sub(last_bytes).saturating_mul(8)
|
||||
/ window_ms) as u32;
|
||||
if let Some(kbps) = abr.on_window(
|
||||
Instant::now(),
|
||||
window_dropped,
|
||||
loss_ppm,
|
||||
owd_mean_us,
|
||||
decode_mean_us,
|
||||
encode_mean_us,
|
||||
actual_kbps,
|
||||
flush_in_window,
|
||||
) {
|
||||
// A discard window feeds the controller NOTHING — its signals are probe-tail
|
||||
// residue, and one "congestion" verdict here ends slow start for good.
|
||||
let verdict = if discard {
|
||||
None
|
||||
} else {
|
||||
abr.on_window(
|
||||
Instant::now(),
|
||||
window_dropped,
|
||||
loss_ppm,
|
||||
owd_mean_us,
|
||||
decode_mean_us,
|
||||
encode_mean_us,
|
||||
actual_kbps,
|
||||
flush_in_window,
|
||||
)
|
||||
};
|
||||
if let Some(kbps) = verdict {
|
||||
// Log the window's signals alongside the decision so an on-glass session can
|
||||
// tell a decode-/encode-driven re-target (the new signals — elevated with
|
||||
// loss/OWD flat) from a network-driven one.
|
||||
|
||||
@@ -82,8 +82,12 @@ pub fn wall_clock_ns() -> u64 {
|
||||
pub enum ResyncStep {
|
||||
/// Nothing — the echo was stale (a previous batch) or no batch is in flight.
|
||||
Idle,
|
||||
/// Send this next-round probe and keep feeding echoes.
|
||||
Probe(ClockProbe),
|
||||
/// The round was recorded and the batch wants another: wait the inter-round spacing, then
|
||||
/// stamp + send [`ClockResync::next_probe`]. Spacing the rounds makes the batch sample
|
||||
/// several phases of the periodic video-burst cycle instead of completing inside one burst
|
||||
/// — at high bitrates the whole 8-round batch otherwise fits in a single ~6 ms burst and
|
||||
/// every round reads the same congested (or same quiet) instant.
|
||||
MoreRounds,
|
||||
/// The batch is complete: the min-RTT estimate over its rounds, per [`clock_offset_ns`].
|
||||
Done { offset_ns: i64, rtt_ns: u64 },
|
||||
}
|
||||
@@ -118,6 +122,13 @@ impl ClockResync {
|
||||
/// `pending_t1` and get ignored. Returns the first probe to send, stamped `now_ns`.
|
||||
pub fn begin(&mut self, now_ns: u64) -> ClockProbe {
|
||||
self.samples.clear();
|
||||
self.next_probe(now_ns)
|
||||
}
|
||||
|
||||
/// Stamp + arm the next round's probe at `now_ns` — send it immediately. Called after
|
||||
/// [`ResyncStep::MoreRounds`] once the caller's inter-round spacing has elapsed; stamping
|
||||
/// at send time keeps that spacing out of the measured RTT.
|
||||
pub fn next_probe(&mut self, now_ns: u64) -> ClockProbe {
|
||||
self.pending_t1 = Some(now_ns);
|
||||
ClockProbe { t1_ns: now_ns }
|
||||
}
|
||||
@@ -129,11 +140,12 @@ impl ClockResync {
|
||||
}
|
||||
self.samples
|
||||
.push((echo.t1_ns, echo.t2_ns, echo.t3_ns, now_ns));
|
||||
if self.samples.len() < Self::ROUNDS {
|
||||
self.pending_t1 = Some(now_ns);
|
||||
return ResyncStep::Probe(ClockProbe { t1_ns: now_ns });
|
||||
}
|
||||
// No probe in flight until the driver arms the next round (or a batch restarts) — a
|
||||
// duplicate of this round's echo must not double-record.
|
||||
self.pending_t1 = None;
|
||||
if self.samples.len() < Self::ROUNDS {
|
||||
return ResyncStep::MoreRounds;
|
||||
}
|
||||
match clock_offset_ns(&self.samples) {
|
||||
Some((offset_ns, rtt_ns)) => ResyncStep::Done { offset_ns, rtt_ns },
|
||||
None => ResyncStep::Idle, // unreachable: ROUNDS > 0 samples were just collected
|
||||
@@ -147,12 +159,97 @@ impl Default for ClockResync {
|
||||
}
|
||||
}
|
||||
|
||||
/// Acceptance guard for a re-sync batch: apply the new offset only when its min RTT is
|
||||
/// comparable to the connect-time RTT — `≤ max(2 ms, 1.5 × connect RTT)`. A congested window
|
||||
/// biases the offset by its queueing delay, and frames already read late exactly then; better
|
||||
/// to keep the old estimate and let the next batch try again.
|
||||
pub fn accept_resync(batch_rtt_ns: u64, connect_rtt_ns: u64) -> bool {
|
||||
batch_rtt_ns <= (connect_rtt_ns + connect_rtt_ns / 2).max(2_000_000)
|
||||
/// Acceptance predicate for a re-sync batch: its min RTT must be comparable to the best RTT
|
||||
/// this session has evidenced — `≤ max(2 ms, 1.5 × floor)`. A congested window biases the
|
||||
/// offset by its queueing delay, and frames already read late exactly then; better to keep the
|
||||
/// old estimate and let the next batch try again.
|
||||
pub fn accept_resync(batch_rtt_ns: u64, floor_rtt_ns: u64) -> bool {
|
||||
batch_rtt_ns <= (floor_rtt_ns + floor_rtt_ns / 2).max(2_000_000)
|
||||
}
|
||||
|
||||
/// Admission decision for a completed re-sync batch (see [`ResyncGuard::admit`]).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ResyncAdmit {
|
||||
/// Batch RTT is within the guard band of the session floor: apply this batch's offset.
|
||||
Fresh,
|
||||
/// Batch rejected (congested window) — keep the previous offset; `streak` counts the
|
||||
/// consecutive rejections since the last applied batch.
|
||||
Rejected { streak: u32 },
|
||||
/// The rejection streak hit [`ResyncGuard::MAX_REJECTED_STREAK`]: apply the best (min-RTT)
|
||||
/// batch of the streak instead of drifting further. Carries that batch's estimate.
|
||||
BestOfStreak { offset_ns: i64, rtt_ns: u64 },
|
||||
}
|
||||
|
||||
/// Admission control for mid-stream re-sync batches. Two fixes over the original static
|
||||
/// `≤ max(2 ms, 1.5 × connect RTT)` guard (2026-07 PyroWave-sawtooth field report):
|
||||
///
|
||||
/// - **The baseline is the session floor, not the connect-time RTT.** The connect handshake
|
||||
/// runs before the video data plane exists; comparing loaded mid-stream batches against that
|
||||
/// idle figure rejected essentially every batch of a high-bitrate LAN session, and the
|
||||
/// offset went stale while the wall clocks drifted apart — the OSD latency ramped for
|
||||
/// minutes and snapped back only when a lucky batch landed. The floor now folds in every
|
||||
/// completed batch's min RTT (rejected ones included: their min-RTT round is still floor
|
||||
/// evidence), so the baseline tracks what this path can actually do under load.
|
||||
/// - **Staleness is bounded.** After [`Self::MAX_REJECTED_STREAK`] consecutive rejections the
|
||||
/// best batch of the streak is applied anyway: its queueing bias is at most ~half its RTT
|
||||
/// (a few ms), while unbounded wall-clock drift is worth that many ms *per minute* on a
|
||||
/// slewing clock. A bounded bias beats an unbounded drift.
|
||||
pub struct ResyncGuard {
|
||||
/// Best RTT this session has evidenced: connect-time RTT, then min over every batch.
|
||||
floor_rtt_ns: u64,
|
||||
rejected_streak: u32,
|
||||
/// Min-RTT batch among the current rejection streak.
|
||||
best_pending: Option<(i64, u64)>,
|
||||
}
|
||||
|
||||
impl ResyncGuard {
|
||||
/// Consecutive rejected batches tolerated before the best of them is applied anyway.
|
||||
pub const MAX_REJECTED_STREAK: u32 = 3;
|
||||
|
||||
pub fn new(connect_rtt_ns: u64) -> ResyncGuard {
|
||||
ResyncGuard {
|
||||
floor_rtt_ns: connect_rtt_ns,
|
||||
rejected_streak: 0,
|
||||
best_pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The current baseline the guard compares batches against (log/debug surface).
|
||||
pub fn floor_rtt_ns(&self) -> u64 {
|
||||
self.floor_rtt_ns
|
||||
}
|
||||
|
||||
/// Judge a completed batch. The caller applies the offset on [`ResyncAdmit::Fresh`] (this
|
||||
/// batch's) or [`ResyncAdmit::BestOfStreak`] (the carried one) and keeps the old offset on
|
||||
/// [`ResyncAdmit::Rejected`].
|
||||
pub fn admit(&mut self, offset_ns: i64, rtt_ns: u64) -> ResyncAdmit {
|
||||
// Judge against the floor as evidenced BEFORE this batch, then fold this batch in —
|
||||
// comparing a batch against a floor that already includes it would accept everything.
|
||||
let fresh = accept_resync(rtt_ns, self.floor_rtt_ns);
|
||||
self.floor_rtt_ns = self.floor_rtt_ns.min(rtt_ns);
|
||||
if fresh {
|
||||
self.rejected_streak = 0;
|
||||
self.best_pending = None;
|
||||
return ResyncAdmit::Fresh;
|
||||
}
|
||||
let best = match self.best_pending {
|
||||
Some((o, r)) if r <= rtt_ns => (o, r),
|
||||
_ => (offset_ns, rtt_ns),
|
||||
};
|
||||
self.best_pending = Some(best);
|
||||
self.rejected_streak += 1;
|
||||
if self.rejected_streak >= Self::MAX_REJECTED_STREAK {
|
||||
self.rejected_streak = 0;
|
||||
self.best_pending = None;
|
||||
return ResyncAdmit::BestOfStreak {
|
||||
offset_ns: best.0,
|
||||
rtt_ns: best.1,
|
||||
};
|
||||
}
|
||||
ResyncAdmit::Rejected {
|
||||
streak: self.rejected_streak,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -218,9 +315,14 @@ mod tests {
|
||||
let echo = echo_for(probe.t1_ns, one_way);
|
||||
let t4 = t4_for(&echo, one_way);
|
||||
match rs.on_echo(&echo, t4) {
|
||||
ResyncStep::Probe(p) => {
|
||||
ResyncStep::MoreRounds => {
|
||||
assert!(round < ClockResync::ROUNDS - 1, "batch overran its rounds");
|
||||
probe = p;
|
||||
// A duplicate of the just-consumed echo must not double-record: no probe
|
||||
// is in flight until the driver arms the next round.
|
||||
assert_eq!(rs.on_echo(&echo, t4), ResyncStep::Idle);
|
||||
// The driver stamps the next probe at SEND time (after its inter-round
|
||||
// spacing), so the spacing never lands in the measured RTT.
|
||||
probe = rs.next_probe(t4 + 7_000_000);
|
||||
}
|
||||
ResyncStep::Done { offset_ns, rtt_ns } => {
|
||||
assert_eq!(round, ClockResync::ROUNDS - 1, "batch ended early");
|
||||
@@ -243,10 +345,46 @@ mod tests {
|
||||
rs.on_echo(&echo_for(old.t1_ns, 100_000), 2_300_000),
|
||||
ResyncStep::Idle
|
||||
);
|
||||
assert!(matches!(
|
||||
assert_eq!(
|
||||
rs.on_echo(&echo_for(fresh.t1_ns, 100_000), 3_300_000),
|
||||
ResyncStep::Probe(_)
|
||||
));
|
||||
ResyncStep::MoreRounds
|
||||
);
|
||||
}
|
||||
|
||||
/// The guard's two field-report fixes: the baseline tracks the SESSION floor (a batch
|
||||
/// better than the stale connect figure re-anchors it), and a rejection streak is bounded
|
||||
/// — the best batch of the streak is applied rather than letting the offset go stale
|
||||
/// while the wall clocks drift apart.
|
||||
#[test]
|
||||
fn resync_guard_floor_tracking_and_bounded_streak() {
|
||||
// Connect measured 400 µs idle; the 2 ms floor of accept_resync governs early on.
|
||||
let mut g = ResyncGuard::new(400_000);
|
||||
assert_eq!(g.admit(10, 1_500_000), ResyncAdmit::Fresh);
|
||||
// A better batch lowers the floor evidence.
|
||||
assert_eq!(g.admit(11, 300_000), ResyncAdmit::Fresh);
|
||||
assert_eq!(g.floor_rtt_ns(), 300_000);
|
||||
|
||||
// Loaded stretch: batches at 4–6 ms all exceed max(2 ms, 1.5 × 300 µs).
|
||||
assert_eq!(g.admit(100, 6_000_000), ResyncAdmit::Rejected { streak: 1 });
|
||||
// The best (min-RTT) batch of the streak is remembered…
|
||||
assert_eq!(g.admit(200, 4_000_000), ResyncAdmit::Rejected { streak: 2 });
|
||||
// …and applied when the streak hits the cap — offset 200 (the 4 ms batch), not 300.
|
||||
assert_eq!(
|
||||
g.admit(300, 5_000_000),
|
||||
ResyncAdmit::BestOfStreak {
|
||||
offset_ns: 200,
|
||||
rtt_ns: 4_000_000
|
||||
}
|
||||
);
|
||||
// The streak reset: the next congested batch starts a new one.
|
||||
assert_eq!(g.admit(400, 5_000_000), ResyncAdmit::Rejected { streak: 1 });
|
||||
// A quiet batch clears it and applies normally.
|
||||
assert_eq!(g.admit(500, 350_000), ResyncAdmit::Fresh);
|
||||
|
||||
// A batch that IS the new floor is always fresh (compared against the pre-batch floor).
|
||||
let mut g2 = ResyncGuard::new(10_000_000);
|
||||
assert_eq!(g2.admit(1, 8_000_000), ResyncAdmit::Fresh);
|
||||
assert_eq!(g2.floor_rtt_ns(), 8_000_000);
|
||||
}
|
||||
|
||||
/// The acceptance guard: a batch measured through a congested window (fat RTT) must not
|
||||
|
||||
@@ -475,3 +475,255 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
println!("dualsense-windows-test: done (devnode removed)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mirror a physical monitor and pull frames from it — the on-glass gate for per-monitor capture
|
||||
/// (`design/per-monitor-portal-capture.md` P2/P3), without needing a client to connect.
|
||||
///
|
||||
/// Opens the display backend exactly as a session would (so a `PUNKTFUNK_CAPTURE_MONITOR` pin
|
||||
/// routes to the mirror backend), attaches a capturer to whatever PipeWire node comes back, and
|
||||
/// reports the frames it actually receives. What it proves that a unit test cannot: the compositor
|
||||
/// accepted the record request for a NAMED head, and that head is producing pixels at its own size.
|
||||
///
|
||||
/// `--monitor <CONNECTOR>` pins for this run (else `PUNKTFUNK_CAPTURE_MONITOR`); `--seconds N`.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn mirror_test(args: &[String]) -> Result<()> {
|
||||
use std::time::{Duration, Instant};
|
||||
let arg = |name: &str| {
|
||||
args.iter()
|
||||
.skip_while(|a| a.as_str() != name)
|
||||
.nth(1)
|
||||
.cloned()
|
||||
};
|
||||
let secs: u64 = arg("--seconds").and_then(|s| s.parse().ok()).unwrap_or(5);
|
||||
// `--monitor` cannot work by setting PUNKTFUNK_CAPTURE_MONITOR here: pf_host_config parses the
|
||||
// environment ONCE and startup already read it, so this process would still see the old
|
||||
// snapshot. An explicit connector therefore goes through `open_mirror` below; only the unset
|
||||
// case falls back to the pin (and to `open`, which is the production routing).
|
||||
let explicit = arg("--monitor");
|
||||
let want = explicit
|
||||
.clone()
|
||||
.or_else(crate::vdisplay::capture_monitor)
|
||||
.context(
|
||||
"no monitor named — pass --monitor <CONNECTOR> or set PUNKTFUNK_CAPTURE_MONITOR",
|
||||
)?;
|
||||
|
||||
let compositor = crate::vdisplay::detect()?;
|
||||
let monitors = crate::vdisplay::monitors::list(compositor)?;
|
||||
let target = crate::vdisplay::monitors::resolve(&monitors, &want)?;
|
||||
println!(
|
||||
"mirror-test: {compositor:?} {} ({}) at +{},+{}",
|
||||
target.connector,
|
||||
target.mode_label(),
|
||||
target.x,
|
||||
target.y
|
||||
);
|
||||
|
||||
// No `--monitor` ⇒ exercise the PRODUCTION routing (`open` consulting the pin), which is the
|
||||
// more valuable path to prove; an explicit connector takes the direct opener.
|
||||
let mut vd = match &explicit {
|
||||
Some(connector) => crate::vdisplay::open_mirror(compositor, connector)?,
|
||||
None => crate::vdisplay::open(compositor)?,
|
||||
};
|
||||
// The mode is ignored by the mirror backend (a panel runs at the mode its owner set); pass the
|
||||
// head's own so this also behaves if the pin is ever unset mid-test.
|
||||
let mode = crate::vdisplay::Mode {
|
||||
width: target.width,
|
||||
height: target.height,
|
||||
refresh_hz: 60,
|
||||
};
|
||||
let vout = vd.create(mode).context("open the mirror display")?;
|
||||
println!(
|
||||
"mirror-test: node_id={} preferred={:?} ownership={:?}",
|
||||
vout.node_id, vout.preferred_mode, vout.ownership
|
||||
);
|
||||
|
||||
// Default to the GPU (dmabuf zero-copy) path a real session uses; `--cpu` forces the mmap
|
||||
// path, which is worth having as a switch — the two negotiate different PipeWire buffer types.
|
||||
let gpu = !args.iter().any(|a| a == "--cpu");
|
||||
let fmt = pf_frame::OutputFormat::resolve(false, gpu);
|
||||
println!(
|
||||
"mirror-test: capture path = {}",
|
||||
if gpu { "gpu/dmabuf" } else { "cpu/mmap" }
|
||||
);
|
||||
let mut cap = crate::capture::capture_virtual_output(
|
||||
vout,
|
||||
fmt,
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
)
|
||||
.context("attach a capturer to the mirrored monitor")?;
|
||||
cap.set_active(true);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
let (mut frames, mut first) = (0u32, None);
|
||||
let mut idle = 0u32;
|
||||
let mut dims = (0u32, 0u32);
|
||||
while Instant::now() < deadline {
|
||||
match cap.next_frame_within(Duration::from_secs(5)) {
|
||||
Ok(f) => {
|
||||
if first.is_none() {
|
||||
first = Some(Instant::now());
|
||||
println!(
|
||||
"mirror-test: FIRST FRAME {}x{} {:?}",
|
||||
f.width, f.height, f.format
|
||||
);
|
||||
}
|
||||
dims = (f.width, f.height);
|
||||
frames += 1;
|
||||
}
|
||||
// A timeout is NOT fatal here: compositor screencast is damage-driven, so a static
|
||||
// desktop legitimately produces nothing for seconds at a time (the host's own capture
|
||||
// diag logs `new_fps=0` for virtual outputs on an idle desktop for the same reason).
|
||||
// Keep waiting until the deadline instead of ending the measurement on the first gap.
|
||||
Err(e) => {
|
||||
idle += 1;
|
||||
if idle == 1 {
|
||||
println!("mirror-test: (idle — no damage yet: {e:#})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match first {
|
||||
Some(_) => println!(
|
||||
"mirror-test: OK — {frames} frames in {secs}s at {}x{} ({:.1} fps over the whole run, \
|
||||
{idle} idle gaps). Compositor capture is damage-driven: a static desktop produces \
|
||||
nothing, so judge this by whether frames track what is happening on screen.",
|
||||
dims.0,
|
||||
dims.1,
|
||||
frames as f64 / secs as f64
|
||||
),
|
||||
None => {
|
||||
anyhow::bail!("no frames arrived in {secs}s — the cast started but produced nothing")
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Aim absolute input at a named monitor and prove where it landed — the on-glass gate for the
|
||||
/// input-region ladder (`design/per-monitor-portal-capture.md` §7.2), without needing a client.
|
||||
///
|
||||
/// The ladder exists for one case a unit test can only simulate: **two heads of the same size**,
|
||||
/// where matching a libei region by the streamed mode is a coin flip and the pointer silently ends
|
||||
/// up on the wrong screen. This drives the real thing — the compositor's own EIS regions, the real
|
||||
/// anchor, the real resolver — and prints the region absolute coordinates actually mapped into, so
|
||||
/// "it went to the right monitor" is something you read rather than infer.
|
||||
///
|
||||
/// `--monitor <CONNECTOR>` anchors at that head's origin (default: the `PUNKTFUNK_CAPTURE_MONITOR` /
|
||||
/// policy pin); `--none` deliberately runs UNANCHORED, which is the A/B that makes the anchored run
|
||||
/// mean something on a same-size pair. It then walks the corners and centre of a `--width`×`--height`
|
||||
/// client surface so an observer can watch the pointer.
|
||||
///
|
||||
/// Read the answer from the log line `libei: absolute input maps into this output`.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn anchor_test(args: &[String]) -> Result<()> {
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use std::time::Duration;
|
||||
let arg = |name: &str| {
|
||||
args.iter()
|
||||
.skip_while(|a| a.as_str() != name)
|
||||
.nth(1)
|
||||
.cloned()
|
||||
};
|
||||
let unanchored = args.iter().any(|a| a == "--none");
|
||||
let w: u32 = arg("--width").and_then(|s| s.parse().ok()).unwrap_or(1920);
|
||||
let h: u32 = arg("--height").and_then(|s| s.parse().ok()).unwrap_or(1080);
|
||||
|
||||
let compositor = crate::vdisplay::detect()?;
|
||||
let monitors = crate::vdisplay::monitors::list(compositor)?;
|
||||
println!(
|
||||
"anchor-test: {compositor:?} has {} monitor(s):",
|
||||
monitors.len()
|
||||
);
|
||||
for m in &monitors {
|
||||
println!(
|
||||
" {:<12} {:>13} at +{},+{}",
|
||||
m.connector,
|
||||
m.mode_label(),
|
||||
m.x,
|
||||
m.y
|
||||
);
|
||||
}
|
||||
// Two heads at the same size is the case the ladder exists for; say so when the rig is right,
|
||||
// and say so when it is NOT — a green run on a single-head box proves nothing about it.
|
||||
let same_size = monitors.iter().enumerate().any(|(i, a)| {
|
||||
monitors
|
||||
.iter()
|
||||
.skip(i + 1)
|
||||
.any(|b| a.width == b.width && a.height == b.height)
|
||||
});
|
||||
println!(
|
||||
"anchor-test: two same-size heads present: {} {}",
|
||||
same_size,
|
||||
if same_size {
|
||||
"— this run exercises the case the ladder exists for"
|
||||
} else {
|
||||
"— WEAK RIG: size matching would have picked correctly anyway"
|
||||
}
|
||||
);
|
||||
|
||||
if unanchored {
|
||||
crate::inject::set_absolute_anchor(None);
|
||||
println!("anchor-test: UNANCHORED (--none) — the size/first rungs decide");
|
||||
} else {
|
||||
let want = arg("--monitor")
|
||||
.or_else(crate::vdisplay::capture_monitor)
|
||||
.context("no monitor named — pass --monitor <CONNECTOR>, or --none for the A/B")?;
|
||||
let m = crate::vdisplay::monitors::resolve(&monitors, &want)?;
|
||||
crate::inject::set_absolute_anchor(Some(crate::inject::AbsoluteAnchor {
|
||||
origin: Some((m.x, m.y)),
|
||||
mapping_id: None,
|
||||
}));
|
||||
println!(
|
||||
"anchor-test: anchored at {} +{},+{} ({})",
|
||||
m.connector,
|
||||
m.x,
|
||||
m.y,
|
||||
m.mode_label()
|
||||
);
|
||||
}
|
||||
|
||||
let backend = crate::inject::default_backend();
|
||||
if backend != crate::inject::Backend::Libei {
|
||||
// The ladder is libei's; on any other backend this command would report nothing about it.
|
||||
// Say so rather than emitting a green run that means nothing (sway injects via WlrVirtual,
|
||||
// which is why the sway box cannot serve as this rig — set PUNKTFUNK_INPUT_BACKEND=libei on
|
||||
// a compositor that speaks EI).
|
||||
anyhow::bail!(
|
||||
"input backend is {backend:?}, not libei — the absolute-region ladder only exists on \
|
||||
the libei backend; set PUNKTFUNK_INPUT_BACKEND=libei"
|
||||
);
|
||||
}
|
||||
let mut inj = crate::inject::open(backend)?;
|
||||
// libei establishes its portal/EIS session + device resume asynchronously; events before then
|
||||
// are dropped (and it is the resume that publishes the regions we are testing).
|
||||
std::thread::sleep(Duration::from_secs(4));
|
||||
|
||||
let flags = (w << 16) | (h & 0xffff);
|
||||
let pts = [
|
||||
(w as i32 / 2, h as i32 / 2),
|
||||
(60, 60),
|
||||
(w as i32 - 60, 60),
|
||||
(w as i32 - 60, h as i32 - 60),
|
||||
(60, h as i32 - 60),
|
||||
(w as i32 / 2, h as i32 / 2),
|
||||
];
|
||||
println!("anchor-test: walking {w}x{h} — centre, four corners, centre (1s apart)");
|
||||
for (x, y) in pts {
|
||||
let e = InputEvent {
|
||||
kind: InputKind::MouseMoveAbs,
|
||||
_pad: [0; 3],
|
||||
code: 0,
|
||||
x,
|
||||
y,
|
||||
flags,
|
||||
};
|
||||
if let Err(err) = inj.inject(&e) {
|
||||
tracing::warn!(error = %format!("{err:#}"), "anchor-test: inject failed");
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
println!(
|
||||
"anchor-test: done — read the `libei: absolute input maps into this output` line above \
|
||||
for the region that was chosen"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -33,10 +33,16 @@ pub struct StreamConfig {
|
||||
pub hdr: bool,
|
||||
}
|
||||
|
||||
/// A pooled capturer plus the two PipeWire-negotiation-time properties reuse must match on —
|
||||
/// its HDR-ness and its metadata-cursor mode; a mismatch on either needs a fresh screencast
|
||||
/// session (see `AppState::video_cap`).
|
||||
pub type PooledCapturer = (Box<dyn Capturer>, bool, bool);
|
||||
/// A pooled capturer plus the three properties reuse must match on — its HDR-ness, its
|
||||
/// metadata-cursor mode (both fixed at PipeWire-negotiation time) and **which screen it is
|
||||
/// actually capturing**: the `capture_monitor` pin, or `None` for the portal's own pick. A
|
||||
/// mismatch on any of them needs a fresh screencast session (see `AppState::video_cap`).
|
||||
///
|
||||
/// The pin belongs in the key because it is a *live* setting — the console can re-aim the host
|
||||
/// between two GameStream connects (`design/per-monitor-portal-capture.md` §7.3). Without it the
|
||||
/// second connect would silently keep streaming the previous screen, which is the exact failure
|
||||
/// the pin exists to prevent.
|
||||
pub type PooledCapturer = (Box<dyn Capturer>, bool, bool, Option<String>);
|
||||
|
||||
/// Slot for the persistent screen capturer, shared with the control plane and reused across
|
||||
/// streams so a reconnect doesn't open a second (conflicting) screencast session.
|
||||
@@ -404,18 +410,31 @@ fn run(
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
false
|
||||
};
|
||||
// Which screen this stream must show. The host-wide pin (§5.3) applies to the compat plane too:
|
||||
// the portal chooser cannot name a head, so a pinned host MIRRORS it here the same way the
|
||||
// virtual source does via `vdisplay::open`. Without this a Moonlight client on a pinned host
|
||||
// would silently get whichever monitor the portal handed back — "showing the wrong monitor is
|
||||
// worse than showing none" is the rule the whole feature is built on.
|
||||
#[cfg(target_os = "linux")]
|
||||
let pinned = crate::vdisplay::capture_monitor();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let pinned: Option<String> = None;
|
||||
let pooled = match video_cap.lock().unwrap().take() {
|
||||
Some((c, was_hdr, was_meta)) if was_hdr == cfg.hdr && was_meta == metadata_cursor => {
|
||||
Some((c, was_hdr, was_meta, ref was_pin))
|
||||
if was_hdr == cfg.hdr && was_meta == metadata_cursor && *was_pin == pinned =>
|
||||
{
|
||||
Some(c)
|
||||
}
|
||||
Some((c, was_hdr, was_meta)) => {
|
||||
Some((c, was_hdr, was_meta, was_pin)) => {
|
||||
tracing::info!(
|
||||
was_hdr,
|
||||
want_hdr = cfg.hdr,
|
||||
was_metadata_cursor = was_meta,
|
||||
want_metadata_cursor = metadata_cursor,
|
||||
"video source: pooled capturer depth/cursor-mode mismatch — opening a fresh \
|
||||
screencast session"
|
||||
was_monitor = was_pin.as_deref().unwrap_or("<portal's pick>"),
|
||||
want_monitor = pinned.as_deref().unwrap_or("<portal's pick>"),
|
||||
"video source: pooled capturer depth/cursor-mode/monitor mismatch — opening a \
|
||||
fresh screencast session"
|
||||
);
|
||||
drop(c);
|
||||
None
|
||||
@@ -427,6 +446,20 @@ fn run(
|
||||
tracing::info!("video source: reusing capturer");
|
||||
c
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
None if pf_host_config::config().video_source.as_deref() == Some("portal")
|
||||
&& pinned.is_some() =>
|
||||
{
|
||||
let connector = pinned.as_deref().expect("guarded by the match arm");
|
||||
tracing::info!(
|
||||
hdr = cfg.hdr,
|
||||
metadata_cursor,
|
||||
monitor = connector,
|
||||
"video source: mirroring the pinned monitor (portal source, host pin)"
|
||||
);
|
||||
open_gs_mirror_source(connector, cfg, metadata_cursor)
|
||||
.with_context(|| format!("mirror the pinned monitor {connector:?}"))?
|
||||
}
|
||||
None if pf_host_config::config().video_source.as_deref() == Some("portal") => {
|
||||
tracing::info!(
|
||||
hdr = cfg.hdr,
|
||||
@@ -463,10 +496,10 @@ fn run(
|
||||
// point — and this path has no rebuild closure (unlike the virtual-output path above), so a
|
||||
// re-admitted dead capturer wedged GameStream portal video permanently, at 10 s per reconnect
|
||||
// attempt. Dropping it instead costs one fresh screencast session on the next connect. Note
|
||||
// `result` may already be `Err` here, which is itself that signal. (`metadata_cursor` rides
|
||||
// along as the second reuse key, beside HDR-ness — see `PooledCapturer`.)
|
||||
// `result` may already be `Err` here, which is itself that signal. (`metadata_cursor` and the
|
||||
// monitor pin ride along as the other two reuse keys, beside HDR-ness — see `PooledCapturer`.)
|
||||
if result.is_ok() && capturer.is_alive() {
|
||||
*video_cap.lock().unwrap() = Some((capturer, cfg.hdr, metadata_cursor));
|
||||
*video_cap.lock().unwrap() = Some((capturer, cfg.hdr, metadata_cursor, pinned));
|
||||
} else {
|
||||
tracing::info!(
|
||||
stream_failed = result.is_err(),
|
||||
@@ -478,6 +511,53 @@ fn run(
|
||||
result
|
||||
}
|
||||
|
||||
/// Open a capturer on the **pinned physical monitor** for the compat plane's portal source
|
||||
/// (`design/per-monitor-portal-capture.md` §5.3). The pin is host-wide, so it has to be honored on
|
||||
/// every plane that captures a screen — and the portal source is the one that otherwise takes
|
||||
/// "whichever head the portal hands back".
|
||||
///
|
||||
/// Deliberately *not* the `open_gs_virtual_source` path: this source launches nothing and creates no
|
||||
/// virtual output, so it needs neither the game-lifetime machinery nor the registry (a mirror is
|
||||
/// [`DisplayOwnership::External`](crate::vdisplay::DisplayOwnership) and would pass straight through
|
||||
/// it anyway). A missing monitor fails the stream loudly rather than falling back to another screen.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn open_gs_mirror_source(
|
||||
connector: &str,
|
||||
cfg: StreamConfig,
|
||||
metadata_cursor: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
// Follow the live session first, exactly as the virtual source does — a mirror host that
|
||||
// switched Desktop↔Game since startup must be enumerated against the compositor that is up now.
|
||||
let active = crate::vdisplay::detect_active_session();
|
||||
crate::vdisplay::observe_session_instance(&active);
|
||||
crate::vdisplay::apply_session_env(&active);
|
||||
let compositor = crate::vdisplay::compositor_for_kind(active.kind)
|
||||
.map(Ok)
|
||||
.unwrap_or_else(crate::vdisplay::detect)
|
||||
.context("detect compositor")?;
|
||||
crate::vdisplay::apply_input_env(compositor, false);
|
||||
let mut vd = crate::vdisplay::open_mirror(compositor, connector)?;
|
||||
// Cursor mode is the session's negotiated one: metadata where this encode path composites
|
||||
// `frame.cursor`, otherwise let the compositor embed it (§7.5 — one resolver, per-backend
|
||||
// expression).
|
||||
vd.set_hw_cursor(metadata_cursor);
|
||||
// The mirror backend ignores the requested mode by design (§7.3 — a panel runs at the mode its
|
||||
// owner set, and the client scales); pass the client's anyway so the argument stays honest.
|
||||
let vout = vd
|
||||
.create(punktfunk_core::Mode {
|
||||
width: cfg.width,
|
||||
height: cfg.height,
|
||||
refresh_hz: cfg.fps,
|
||||
})
|
||||
.context("start mirroring the pinned monitor")?;
|
||||
crate::capture::capture_virtual_output(
|
||||
vout,
|
||||
pf_frame::OutputFormat::resolve(cfg.hdr, crate::zerocopy::enabled()),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
)
|
||||
.context("attach a capturer to the mirrored monitor")
|
||||
}
|
||||
|
||||
/// What the compat plane resolved about the app a client launched: identity for the lease, the status
|
||||
/// surface and the `game.*` events; the signals that recognize the running game; and the command to
|
||||
/// run it.
|
||||
|
||||
@@ -191,6 +191,77 @@ fn main() {
|
||||
/// prints an alarming `SetProcessDpiAwarenessContext … "access denied"` WARN on a plain
|
||||
/// `plugins add`. `service run` is the SCM-launched host itself, so it is explicitly NOT lightweight
|
||||
/// (it must keep the hook — the hybrid-GPU ACCESS_LOST fix depends on it).
|
||||
/// Resolve the effective monitor pin (env, else the stored policy) and aim absolute input at that
|
||||
/// head — then report it. Called at startup (an operator sets the pin in a `host.env` and then has
|
||||
/// no session to watch, so this is where a typo has to surface) and again whenever the console
|
||||
/// writes the policy, so a picker change re-aims input without a host restart.
|
||||
///
|
||||
/// The anchor lives HERE rather than in the mirror backend for two reasons: pf-vdisplay must not
|
||||
/// depend on pf-inject (its crate doc), and the anchor is a host-level pin anyway — the injector is
|
||||
/// host-lifetime and shared by every concurrent session, so there is nothing per-session to track
|
||||
/// (`design/per-monitor-portal-capture.md` §7.2).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn refresh_capture_monitor_anchor(context: &str) {
|
||||
let Some(want) = pf_vdisplay::capture_monitor() else {
|
||||
// No pin (or the console just cleared one): stop aiming input at a monitor we are no
|
||||
// longer mirroring, or a later virtual-display session inherits a stale anchor.
|
||||
// Setting a pin says so in the log; clearing one is the same size of change to what the
|
||||
// host streams, so say that too — but only when there WAS one, or every unpinned host
|
||||
// logs this line at startup for no reason.
|
||||
if pf_inject::absolute_anchor().is_some() {
|
||||
tracing::info!(
|
||||
context,
|
||||
"capture monitor: cleared — sessions create a virtual display again and absolute \
|
||||
input is no longer anchored"
|
||||
);
|
||||
}
|
||||
pf_inject::set_absolute_anchor(None);
|
||||
return;
|
||||
};
|
||||
match pf_vdisplay::detect().and_then(pf_vdisplay::monitors::list) {
|
||||
Ok(ms) => match pf_vdisplay::monitors::resolve(&ms, &want) {
|
||||
Ok(m) => {
|
||||
// Match the libei region by the head's ORIGIN: two monitors can share a size — and
|
||||
// a mirrored head's region is not the client's size at all — so size matching would
|
||||
// put the pointer on the wrong screen.
|
||||
pf_inject::set_absolute_anchor(Some(pf_inject::AbsoluteAnchor {
|
||||
origin: Some((m.x, m.y)),
|
||||
mapping_id: None,
|
||||
}));
|
||||
tracing::info!(
|
||||
context,
|
||||
connector = %m.connector,
|
||||
description = %m.description,
|
||||
mode = %m.mode_label(),
|
||||
at = %format!("+{}+{}", m.x, m.y),
|
||||
"capture monitor: sessions will mirror this monitor (no virtual display) and \
|
||||
absolute input is anchored to it"
|
||||
);
|
||||
}
|
||||
// Left unanchored on purpose: a pin that resolves to nothing must not aim input at a
|
||||
// guess. The session's own `create` fails with the same reason.
|
||||
Err(e) => {
|
||||
pf_inject::set_absolute_anchor(None);
|
||||
tracing::warn!(
|
||||
context,
|
||||
error = %e,
|
||||
"capture monitor: the pinned monitor is not on this host — sessions will fail \
|
||||
to start until it is corrected or cleared"
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
pf_inject::set_absolute_anchor(None);
|
||||
tracing::warn!(
|
||||
context,
|
||||
error = %format!("{e:#}"),
|
||||
monitor = %want,
|
||||
"capture monitor: a monitor is pinned but the monitors could not be enumerated"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_management_cli(args: &[String]) -> bool {
|
||||
match args.first().map(String::as_str) {
|
||||
Some("plugins")
|
||||
@@ -199,6 +270,9 @@ fn is_management_cli(args: &[String]) -> bool {
|
||||
| Some("openapi")
|
||||
| Some("library")
|
||||
| Some("detect-conflicts")
|
||||
// Reads the compositor's output list and exits — none of the host-startup work applies,
|
||||
// and it must not re-run the pin's own startup report while printing that same list.
|
||||
| Some("list-monitors")
|
||||
| Some("-h")
|
||||
| Some("--help")
|
||||
| Some("help")
|
||||
@@ -231,6 +305,11 @@ fn real_main() -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if !management_cli {
|
||||
refresh_capture_monitor_anchor("startup");
|
||||
}
|
||||
|
||||
// Wire pf-vdisplay's display-lifecycle events into the SSE event bus (the subsystem crate emits a
|
||||
// neutral DisplayEvent; the orchestrator owns the bus type — plan §W6). Set once, ignore re-set.
|
||||
let _ = pf_vdisplay::DISPLAY_EVENT_SINK.set(Box::new(|ev| match ev {
|
||||
@@ -411,6 +490,62 @@ fn real_main() -> Result<()> {
|
||||
println!("{compositor:?} ready");
|
||||
Ok(())
|
||||
}
|
||||
// List the host's physical monitors — the connector names `PUNKTFUNK_CAPTURE_MONITOR`
|
||||
// takes. An operator configuring an unattended host has to learn those names from
|
||||
// somewhere, and "curl the management API before the host is configured" is not it.
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("list-monitors") => {
|
||||
let compositor = vdisplay::detect()?;
|
||||
let monitors = vdisplay::monitors::list(compositor)
|
||||
.with_context(|| format!("enumerate monitors on {compositor:?}"))?;
|
||||
if monitors.is_empty() {
|
||||
println!("{compositor:?}: no monitors");
|
||||
return Ok(());
|
||||
}
|
||||
let pinned = vdisplay::capture_monitor();
|
||||
println!("{compositor:?}:");
|
||||
for m in &monitors {
|
||||
let mut tags = Vec::new();
|
||||
if m.primary {
|
||||
tags.push("primary");
|
||||
}
|
||||
if !m.enabled {
|
||||
tags.push("disabled");
|
||||
}
|
||||
if m.managed {
|
||||
tags.push("punktfunk virtual display");
|
||||
}
|
||||
if pinned
|
||||
.as_deref()
|
||||
.is_some_and(|p| p.eq_ignore_ascii_case(&m.connector))
|
||||
{
|
||||
tags.push("PINNED");
|
||||
}
|
||||
println!(
|
||||
" {:<12} {:>13} at +{},+{} scale {} {}{}",
|
||||
m.connector,
|
||||
m.mode_label(),
|
||||
m.x,
|
||||
m.y,
|
||||
m.scale,
|
||||
m.description,
|
||||
if tags.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" [{}]", tags.join(", "))
|
||||
}
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
// Mirror a pinned physical monitor and pull frames from it — the per-monitor capture
|
||||
// on-glass gate, with no client involved.
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("mirror-test") => devtest::mirror_test(&args),
|
||||
// Aim absolute input at a named monitor and report which libei region it landed in — the
|
||||
// on-glass gate for the input-region ladder, with no client involved.
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("anchor-test") => devtest::anchor_test(&args),
|
||||
// Create a virtual DualSense via UHID and exercise it (validation, no streaming session).
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("dualsense-test") => devtest::dualsense_test(&args),
|
||||
@@ -733,6 +868,8 @@ USAGE:
|
||||
punktfunk-host openapi print the management API's OpenAPI document (codegen)
|
||||
punktfunk-host punktfunk1-host [OPTIONS] native punktfunk/1 host (QUIC control + UDP data plane)
|
||||
punktfunk-host probe-compositor exit 0 iff the compositor is up + ready (bringup gate)
|
||||
punktfunk-host list-monitors list the host's physical monitors (Linux) — the
|
||||
connector names PUNKTFUNK_CAPTURE_MONITOR takes
|
||||
punktfunk-host spike [OPTIONS] capture→encode→file pipeline spike (dev tool)
|
||||
|
||||
SERVE OPTIONS:
|
||||
|
||||
@@ -188,6 +188,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
.routes(routes!(display::get_display_settings))
|
||||
.routes(routes!(display::set_display_settings))
|
||||
.routes(routes!(display::get_display_state))
|
||||
.routes(routes!(display::get_display_monitors))
|
||||
.routes(routes!(display::release_display))
|
||||
.routes(routes!(display::set_display_layout))
|
||||
.routes(routes!(
|
||||
|
||||
@@ -88,6 +88,9 @@ pub(crate) fn display_settings_state() -> DisplaySettingsState {
|
||||
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
|
||||
"ddc_power_off".into(),
|
||||
"pnp_disable_monitors".into(),
|
||||
// Linux-only in effect: routes every session to the mirror backend
|
||||
// (design/per-monitor-portal-capture.md).
|
||||
"capture_monitor".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -141,6 +144,10 @@ pub(crate) async fn set_display_settings(
|
||||
);
|
||||
}
|
||||
tracing::info!("management API: display policy updated");
|
||||
// The policy carries the capture-monitor pin, so a picker change must re-aim absolute input now
|
||||
// rather than at the next host restart — and must clear the anchor when the pin is cleared.
|
||||
#[cfg(target_os = "linux")]
|
||||
crate::refresh_capture_monitor_anchor("display policy updated");
|
||||
Json(display_settings_state()).into_response()
|
||||
}
|
||||
|
||||
@@ -181,6 +188,105 @@ pub(crate) struct DisplayStateResponse {
|
||||
displays: Vec<ApiDisplayInfo>,
|
||||
}
|
||||
|
||||
/// One physical monitor this host has, as the compositor reports it.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct ApiMonitorInfo {
|
||||
/// Connector name (`DP-1`, `HDMI-A-2`) — the value `PUNKTFUNK_CAPTURE_MONITOR` takes.
|
||||
connector: String,
|
||||
/// Human label for a picker (`make model`, else the connector).
|
||||
description: String,
|
||||
/// `WIDTHxHEIGHT@HZ` of the current mode (size only when the refresh is unknown).
|
||||
mode: String,
|
||||
/// Desktop-space top-left — what makes a head identifiable when two share a size.
|
||||
x: i32,
|
||||
y: i32,
|
||||
/// Logical scale factor.
|
||||
scale: f64,
|
||||
/// The compositor's primary/focused head.
|
||||
primary: bool,
|
||||
/// Driven right now. A disabled head is still listed, so it can be explained rather than missing.
|
||||
enabled: bool,
|
||||
/// Best-effort: this is one of OUR virtual displays, not a real head (reliable on KWin only).
|
||||
managed: bool,
|
||||
/// True when `PUNKTFUNK_CAPTURE_MONITOR` currently names this monitor.
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
/// The host's physical monitors + which one capture is pinned to.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct MonitorsResponse {
|
||||
/// Compositor backend the enumeration came from (`kwin`, `mutter`, …), when one was resolved.
|
||||
compositor: Option<String>,
|
||||
/// The heads, ordered left-to-right by desktop position.
|
||||
monitors: Vec<ApiMonitorInfo>,
|
||||
/// The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing,
|
||||
/// so the console can show "pinned to DP-2, which this host doesn't have".
|
||||
pinned: Option<String>,
|
||||
/// Why the list is empty, when enumeration failed (compositor unreachable, unsupported
|
||||
/// platform). `None` with an empty list means "asked, and there are none".
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Physical monitors
|
||||
///
|
||||
/// The heads this host actually has — for pinning capture at one (`PUNKTFUNK_CAPTURE_MONITOR`) and
|
||||
/// for rendering a picker. Read-only: this never creates, moves or disables anything. Note these
|
||||
/// are *not* the managed virtual displays — those are `/display/state`. See
|
||||
/// `design/per-monitor-portal-capture.md` §5.1.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/display/monitors",
|
||||
tag = "display",
|
||||
operation_id = "getDisplayMonitors",
|
||||
responses(
|
||||
(status = OK, description = "The host's physical monitors", body = MonitorsResponse),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_display_monitors() -> Json<MonitorsResponse> {
|
||||
// The EFFECTIVE pin (env override, else the stored policy) — so the picker highlights what
|
||||
// sessions will actually mirror, not just what the console last wrote.
|
||||
#[cfg(target_os = "linux")]
|
||||
let pinned = crate::vdisplay::capture_monitor();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let pinned: Option<String> = None;
|
||||
// Enumeration shells out / round-trips D-Bus + Wayland, so keep it off the async worker.
|
||||
let (compositor, listed) = tokio::task::spawn_blocking(|| match crate::vdisplay::detect() {
|
||||
Ok(c) => (Some(c.id().to_string()), crate::vdisplay::monitors::list(c)),
|
||||
Err(e) => (None, Err(e)),
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| (None, Err(anyhow::anyhow!("enumeration task failed: {e}"))));
|
||||
let (monitors, error) = match listed {
|
||||
Ok(ms) => (
|
||||
ms.into_iter()
|
||||
.map(|m| ApiMonitorInfo {
|
||||
mode: m.mode_label(),
|
||||
selected: pinned
|
||||
.as_deref()
|
||||
.is_some_and(|p| p.eq_ignore_ascii_case(&m.connector)),
|
||||
connector: m.connector,
|
||||
description: m.description,
|
||||
x: m.x,
|
||||
y: m.y,
|
||||
scale: m.scale,
|
||||
primary: m.primary,
|
||||
enabled: m.enabled,
|
||||
managed: m.managed,
|
||||
})
|
||||
.collect(),
|
||||
None,
|
||||
),
|
||||
Err(e) => (Vec::new(), Some(format!("{e:#}"))),
|
||||
};
|
||||
Json(MonitorsResponse {
|
||||
compositor,
|
||||
monitors,
|
||||
pinned,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
/// Request body for `releaseDisplay`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct ReleaseDisplayRequest {
|
||||
@@ -298,6 +404,7 @@ pub(crate) async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutReques
|
||||
store.game_session(),
|
||||
store.ddc_power_off(),
|
||||
store.pnp_disable_monitors(),
|
||||
store.get().capture_monitor,
|
||||
);
|
||||
if let Err(e) = store.set(policy) {
|
||||
return api_error(
|
||||
|
||||
@@ -1056,6 +1056,34 @@ async fn display_state_and_release_empty() {
|
||||
assert_eq!(body["released"], 0);
|
||||
}
|
||||
|
||||
/// `/display/monitors` is wired, auth-gated, and — the point of the test — **always answers 200
|
||||
/// with a well-formed envelope**, including on a test host with no compositor to enumerate. The
|
||||
/// console renders a picker from this; an enumeration failure has to arrive as an `error` string
|
||||
/// next to an empty list, never as a 5xx that reads to the UI as "the host is broken".
|
||||
#[tokio::test]
|
||||
async fn display_monitors_answers_even_with_no_compositor() {
|
||||
let app = test_app(test_state(), None);
|
||||
|
||||
let (status, body) = send(&app, get_req("/api/v1/display/monitors")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body["monitors"].is_array(), "monitors is always an array");
|
||||
// No compositor on the test host ⇒ either a clean empty list or an explained failure, never
|
||||
// both empty AND silent about why.
|
||||
let listed = body["monitors"].as_array().map(|a| a.len()).unwrap_or(0);
|
||||
// gamescope is nested: it owns no physical heads by construction, so "empty and silent" is the
|
||||
// correct answer there, not an unexplained one. A dev box that has ever been in game mode keeps
|
||||
// `gamescope-0` sockets in its runtime dir, so `detect()` resolves gamescope and this test would
|
||||
// otherwise fail on the machine rather than on the code (found running the suite on .136).
|
||||
let nested = body["compositor"] == "gamescope";
|
||||
assert!(
|
||||
listed > 0 || !body["error"].is_null() || body["compositor"].is_null() || nested,
|
||||
"an empty list must carry an error, an absent compositor, or a nested one: {body}"
|
||||
);
|
||||
// The pin is reported verbatim so the console can flag "pinned to a monitor you don't have";
|
||||
// unset on the test host.
|
||||
assert!(body["pinned"].is_null(), "no PUNKTFUNK_CAPTURE_MONITOR set");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_pairing_arm_show_and_unpair() {
|
||||
let np = Arc::new(
|
||||
|
||||
@@ -350,6 +350,8 @@ pub(crate) async fn serve(
|
||||
// A3: recover a TV takeover stranded by a crashed previous host instance (persisted to
|
||||
// $XDG_RUNTIME_DIR) — schedule a restore after a reconnect grace. No-op on a clean start.
|
||||
crate::vdisplay::restore_takeover_on_startup();
|
||||
// …and the other end of that: give the box its session back when WE are the ones going away.
|
||||
install_shutdown_restore();
|
||||
// Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com /
|
||||
// displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never
|
||||
// blocks on the network. A no-op on a host whose stores all carry their own art.
|
||||
@@ -486,6 +488,58 @@ pub(crate) async fn serve(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long a shutdown waits for the box's session to be handed back before exiting anyway. The
|
||||
/// restore is a couple of `systemctl` calls (or one `pkexec` helper run); this only bounds a
|
||||
/// genuine wedge, well inside systemd's 90 s `TimeoutStopSec`.
|
||||
const SHUTDOWN_RESTORE_GRACE: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
/// Hand the box's own session back on the way out. Until this existed the host had NO signal
|
||||
/// handling at all: `SIGTERM` killed it outright, which is fine for a host that owns nothing — but
|
||||
/// a managed gamescope takeover owns the box's session, and on a mask-fragile display manager
|
||||
/// (Nobara's plasmalogin) it has STOPPED that display manager for the length of the stream. Killed
|
||||
/// there, the host leaves a box with no graphical session and nothing left to restart it: the
|
||||
/// crash-restore state lives in `$XDG_RUNTIME_DIR`, which logind removes along with the user
|
||||
/// manager, so even the next host start can't heal it. `systemctl --user restart punktfunk-host`
|
||||
/// mid-stream — or a package update doing it for you — was enough.
|
||||
///
|
||||
/// So: catch `SIGTERM`/`SIGINT`, restore, then exit. Restoring runs on a blocking thread (it shells
|
||||
/// out) under [`SHUTDOWN_RESTORE_GRACE`], and a host that took nothing over exits immediately.
|
||||
fn install_shutdown_restore() {
|
||||
#[cfg(unix)]
|
||||
tokio::spawn(async {
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
let (Ok(mut term), Ok(mut int)) = (
|
||||
signal(SignalKind::terminate()),
|
||||
signal(SignalKind::interrupt()),
|
||||
) else {
|
||||
tracing::warn!(
|
||||
"could not install shutdown signal handlers — a host stopped mid-takeover will \
|
||||
leave the box's own session down until it is restarted"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let sig = tokio::select! {
|
||||
_ = term.recv() => "SIGTERM",
|
||||
_ = int.recv() => "SIGINT",
|
||||
};
|
||||
tracing::info!(
|
||||
signal = sig,
|
||||
"host stopping — handing the box's session back"
|
||||
);
|
||||
let restore = tokio::task::spawn_blocking(crate::vdisplay::restore_takeover_now);
|
||||
if tokio::time::timeout(SHUTDOWN_RESTORE_GRACE, restore)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
secs = SHUTDOWN_RESTORE_GRACE.as_secs(),
|
||||
"the session restore did not finish in time — exiting anyway"
|
||||
);
|
||||
}
|
||||
std::process::exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
/// The accept loop is sequential, so the control phase must be bounded — a client that
|
||||
/// connects and never finishes the handshake would otherwise wedge the host for everyone.
|
||||
const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
@@ -949,6 +1003,9 @@ async fn serve_session(
|
||||
// so a resize would resolve a DIFFERENT slot — on Windows a fresh monitor ADD instead of the
|
||||
// in-place reconfigure, on KWin a differently-named output — defeating the policy's
|
||||
// per-resolution identity. Honest downgrade: reject, client scales (H5).
|
||||
// * a monitor MIRROR (a `capture_monitor` pin): a physical head runs at the mode its owner set
|
||||
// and the mirror backend ignores the requested one, so a resize would restart the identical
|
||||
// cast at the identical size (design/per-monitor-portal-capture.md §7.3).
|
||||
// The SYNTHETIC source stays reconfigurable on purpose (nothing to rebuild — the ack round-trip
|
||||
// is the whole effect): it is the compositor-free protocol test source, and the C-ABI roundtrip
|
||||
// test + client harnesses exercise the Reconfigure/Reconfigured plumbing through it.
|
||||
@@ -957,7 +1014,16 @@ async fn serve_session(
|
||||
let per_client_mode_identity = crate::vdisplay::policy::prefs()
|
||||
.configured_effective()
|
||||
.is_some_and(|e| e.identity == crate::vdisplay::policy::Identity::PerClientMode);
|
||||
reconfig_allowed(compositor, per_client_mode_identity)
|
||||
// Read once here, like the identity above: this session opened its display under whatever
|
||||
// the pin said at bring-up, so a console change mid-session must not retroactively change
|
||||
// what THIS session answers a Reconfigure with. Linux-only because `vdisplay::open` only
|
||||
// routes to the mirror there — a pin left in a Windows host's settings streams nothing
|
||||
// different, and must not silently disable resize as a side effect.
|
||||
#[cfg(target_os = "linux")]
|
||||
let mirrored = crate::vdisplay::capture_monitor().is_some();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let mirrored = false;
|
||||
reconfig_allowed(compositor, per_client_mode_identity, mirrored)
|
||||
};
|
||||
// Negotiated codec (HEVC / H.264 / AV1), derived from the Welcome. `Copy`, so the control task's
|
||||
// `async move` captures a copy and it stays usable for the data-plane SessionContext below.
|
||||
|
||||
@@ -335,6 +335,11 @@ struct FrameMsg {
|
||||
/// nor a stats capture is armed). The send thread accumulates them for the web-console sample:
|
||||
/// `cap_us` = `try_latest` (ring read + colour convert), `submit_us` = NVENC `encode_picture`
|
||||
/// launch, `wait_us` = `lock_bitstream` (the scheduling wait + ASIC encode = the "encode" stage).
|
||||
/// SYNCHRONOUS backends (PyroWave: the whole GPU encode + fence wait runs inside `submit`)
|
||||
/// carry their real encode time in `submit_us`, and the "encode" stage reads ~0 by
|
||||
/// construction — read the pair together (the 2026-07 field triage read "encode 0.00" as an
|
||||
/// instrumentation hole; it's the stage split's shape). The client-facing 0xCF `encode_us`
|
||||
/// is unaffected: its anchor is stamped before `submit`, so it spans both.
|
||||
cap_us: u32,
|
||||
submit_us: u32,
|
||||
wait_us: u32,
|
||||
@@ -491,13 +496,20 @@ struct SendStats {
|
||||
/// * a **per-client-mode identity** policy: the mode is part of the display-identity slot key, so a
|
||||
/// resize resolves a DIFFERENT slot (a fresh Windows monitor / a differently-named KWin output),
|
||||
/// defeating the policy — honest downgrade is to reject and let the client scale.
|
||||
/// * a **monitor mirror** (`mirrored`): the source is a physical head running at the mode its owner
|
||||
/// set, and `MirrorDisplay::create` ignores the requested one by design
|
||||
/// (design/per-monitor-portal-capture.md §7.3). A resize would tear the cast down and re-`create`
|
||||
/// the *same* head at the *same* size — a visible hitch that changes nothing — or, worse, invite
|
||||
/// the reflex of reconfiguring the display someone is sitting in front of. Reject; the client
|
||||
/// scales, exactly as it already does for gamescope.
|
||||
///
|
||||
/// Every other compositor (and the synthetic protocol-test source) with the default identity accepts.
|
||||
pub(super) fn reconfig_allowed(
|
||||
compositor: Option<crate::vdisplay::Compositor>,
|
||||
per_client_mode: bool,
|
||||
mirrored: bool,
|
||||
) -> bool {
|
||||
compositor != Some(crate::vdisplay::Compositor::Gamescope) && !per_client_mode
|
||||
compositor != Some(crate::vdisplay::Compositor::Gamescope) && !per_client_mode && !mirrored
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1118,9 +1130,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
tracing::info!("gamescope cursor: compositing the XFixes-sourced pointer into the video");
|
||||
}
|
||||
if streamed_wire {
|
||||
// Client capability only — whether AUs actually stream per-slice depends on the encoder
|
||||
// backend's `supports_chunked_poll()` (today: Linux direct-NVENC only), which doesn't
|
||||
// exist yet at this point. The old wording ("chunked encoder output will stream
|
||||
// per-slice") sent a 2026-07 field triage chasing a streaming path AMF doesn't have.
|
||||
tracing::info!(
|
||||
"client accepts streamed AUs (VIDEO_CAP_STREAMED_AU) — chunked encoder output \
|
||||
will stream per-slice"
|
||||
"client accepts streamed AUs (VIDEO_CAP_STREAMED_AU) — used if this session's \
|
||||
encoder supports chunked output"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
@@ -3515,22 +3531,39 @@ mod tests {
|
||||
use crate::vdisplay::Compositor::{Gamescope, Hyprland, Kwin, Mutter, Wlroots};
|
||||
// gamescope ALWAYS rejects — a resize would respawn the nested game (H1/D3), regardless of
|
||||
// the identity policy.
|
||||
assert!(!reconfig_allowed(Some(Gamescope), false));
|
||||
assert!(!reconfig_allowed(Some(Gamescope), true));
|
||||
assert!(!reconfig_allowed(Some(Gamescope), false, false));
|
||||
assert!(!reconfig_allowed(Some(Gamescope), true, false));
|
||||
// A per-client-mode identity policy rejects on every backend — the resize resolves a
|
||||
// different display-identity slot (H5).
|
||||
assert!(!reconfig_allowed(Some(Kwin), true));
|
||||
assert!(!reconfig_allowed(Some(Mutter), true));
|
||||
assert!(!reconfig_allowed(None, true));
|
||||
assert!(!reconfig_allowed(Some(Kwin), true, false));
|
||||
assert!(!reconfig_allowed(Some(Mutter), true, false));
|
||||
assert!(!reconfig_allowed(None, true, false));
|
||||
// Every other compositor with the default identity ACCEPTS (recreate / re-arrival / in-place).
|
||||
for c in [Kwin, Mutter, Wlroots, Hyprland] {
|
||||
assert!(
|
||||
reconfig_allowed(Some(c), false),
|
||||
reconfig_allowed(Some(c), false, false),
|
||||
"{c:?} should allow live reconfigure"
|
||||
);
|
||||
}
|
||||
// The synthetic source (no compositor) is the protocol-test path — always reconfigurable.
|
||||
assert!(reconfig_allowed(None, false));
|
||||
assert!(reconfig_allowed(None, false, false));
|
||||
}
|
||||
|
||||
/// A mirrored physical head has a fixed mode (§7.3): every backend that would otherwise accept
|
||||
/// a live reconfigure must reject one while the session is streaming someone's real monitor.
|
||||
#[test]
|
||||
fn reconfig_allowed_rejects_a_monitor_mirror_on_every_backend() {
|
||||
use crate::vdisplay::Compositor::{Hyprland, Kwin, Mutter, Wlroots};
|
||||
for c in [Kwin, Mutter, Wlroots, Hyprland] {
|
||||
assert!(
|
||||
reconfig_allowed(Some(c), false, false),
|
||||
"{c:?} without a pin should still allow live reconfigure"
|
||||
);
|
||||
assert!(
|
||||
!reconfig_allowed(Some(c), false, true),
|
||||
"{c:?} mirroring a physical head must reject a resize"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -45,7 +45,9 @@ pub struct StatsSample {
|
||||
pub fps: f32,
|
||||
/// Re-encoded holds/s (source-starvation indicator).
|
||||
pub repeat_fps: f32,
|
||||
/// Transmit goodput (Mb/s).
|
||||
/// Attempted sealed wire bytes/s (Mb/s): full UDP payloads at seal time — video AU bytes
|
||||
/// plus shard framing (header + AEAD) plus FEC parity, and for PyroWave's datagram-aligned
|
||||
/// mode the zero-padded window tails. NOT goodput, and NOT reduced by socket send drops.
|
||||
pub mbps: f32,
|
||||
/// Configured target bitrate.
|
||||
pub bitrate_kbps: u32,
|
||||
|
||||
@@ -36,6 +36,7 @@ redundant or stale.
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_COMPOSITOR` | `kwin` · `mutter` · `gamescope` · `wlroots` · `hyprland` (aliases: `kde`/`plasma`, `gnome`, `sway`/`wlr`) | Which backend creates the virtual display. `wlroots` is sway/River; `hyprland` is its own backend. **Leave unset.** Setting it **pins** the backend and turns session-following **off** — per connect *and* mid-stream, so a Desktop ↔ Gaming switch kills the stream instead of being followed. For CI/tests and dedicated single-session appliances only. |
|
||||
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. |
|
||||
| `PUNKTFUNK_CAPTURE_MONITOR` | a connector name (`HDMI-A-1`, `DP-2`, …) | Stream a **physical** monitor this host already has instead of creating a virtual display — see [Streamed screen](/docs/virtual-displays#stream-a-real-monitor-instead). List the names with `punktfunk-host list-monitors`. Setting it here **outranks the web console's** choice, so an appliance stays aimed where its operator pointed it; leave it unset to steer from the console. A name that matches no monitor fails the session loudly rather than streaming a different screen. Linux only. |
|
||||
| `PUNKTFUNK_ZEROCOPY` | `1` · `0` *(default on)* | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). **On by default** — no need to set it; it falls back to a CPU path automatically. Set `0` to force the CPU path. One exception: Windows **Intel/QSV** keeps the CPU path by default until zero-copy is validated on Intel hardware — set `1` to try it there. |
|
||||
| `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots **and Hyprland**. Auto-detected with the compositor. |
|
||||
| `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). On a multi-GPU Windows box a forced hardware backend whose vendor contradicts the selected GPU (web-console preference) is **overridden** — the adapter wins and the host logs a warning; remove the stale pin. |
|
||||
|
||||
@@ -69,6 +69,14 @@ depends on the display manager driving the autologin:
|
||||
restart ever loses its privilege mid-restore, `PUNKTFUNK_RECOVER_SESSION_CMD` (see
|
||||
[Configuration](/docs/configuration)) is fired as the fallback.
|
||||
|
||||
**Lingering is required here**, and the host turns it on for you the first time it takes the box
|
||||
over. Stopping the display manager ends your last login session, and without
|
||||
`loginctl enable-linger` logind stops your `systemd --user` manager about ten seconds later —
|
||||
taking the host with it, mid-stream, with the display manager down and nothing left to bring it
|
||||
back. If lingering can't be enabled the host refuses the takeover and degrades to attach instead
|
||||
(above) rather than risk that. Run `sudo loginctl enable-linger "$USER"` once, as the setup guides
|
||||
ask; `loginctl disable-linger "$USER"` reverts it.
|
||||
|
||||
With the takeover authorized 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.
|
||||
|
||||
@@ -86,6 +86,33 @@ hosts from another machine with `punktfunk-probe --discover`. Where multicast do
|
||||
Docker/VLAN setups), pass `--no-mdns` (or set `PUNKTFUNK_MDNS=0`) and add the host in the client by
|
||||
address instead.
|
||||
|
||||
## `list-monitors`
|
||||
|
||||
`punktfunk-host list-monitors` prints the **physical** monitors this host's compositor has, by
|
||||
connector name — which is how you name one for [Streamed
|
||||
screen](/docs/virtual-displays#stream-a-real-monitor-instead) (in the console, or as
|
||||
`PUNKTFUNK_CAPTURE_MONITOR`).
|
||||
|
||||
```sh
|
||||
punktfunk-host list-monitors
|
||||
```
|
||||
|
||||
```
|
||||
Kwin:
|
||||
HDMI-A-1 1920x1080@60 at +0,+0 scale 1 Dell U2412M [primary]
|
||||
DP-2 2560x1440@144 at +1920,+0 scale 1 ACME 27 [PINNED]
|
||||
```
|
||||
|
||||
Tags flag what's worth knowing before you pick: `primary`, `disabled` (nothing to stream), `punktfunk
|
||||
virtual display` (one of ours, not a real head), and `PINNED` for the one currently selected. Linux
|
||||
only — it reads the live compositor, so run it in (or with the environment of) the session you want
|
||||
to stream.
|
||||
|
||||
`punktfunk-host mirror-test --monitor <CONNECTOR> [--seconds N] [--cpu]` then proves the whole path —
|
||||
mirror, capture, frames — with no client involved. It reports the first frame, the frame count and
|
||||
the negotiated size. Screen recording is damage-driven, so move the mouse on the host while it runs;
|
||||
an idle desktop legitimately yields almost nothing.
|
||||
|
||||
## `detect-conflicts`
|
||||
|
||||
`punktfunk-host detect-conflicts` reports other Moonlight-compatible hosts (Sunshine, Apollo, and
|
||||
|
||||
@@ -26,6 +26,35 @@ systemctl --user enable --now punktfunk-host
|
||||
|
||||
The host now starts whenever you log in. Check it with `systemctl --user status punktfunk-host`.
|
||||
|
||||
**You don't need to export anything for it.** The host finds the live compositor session itself on
|
||||
every connect and works out where to reach it (`WAYLAND_DISPLAY`, `XDG_RUNTIME_DIR`, the session bus,
|
||||
sway's `SWAYSOCK`, Hyprland's instance signature) from the running compositor — so `host.env` is for
|
||||
policy, not session plumbing, and `systemctl --user import-environment` is not a prerequisite.
|
||||
|
||||
### Restart the host with your desktop
|
||||
|
||||
Add one drop-in so the host follows your session's lifetime:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/systemd/user/punktfunk-host.service.d
|
||||
# /usr/share/punktfunk/ on Fedora/Arch, /usr/share/punktfunk-host/ on Debian/Ubuntu,
|
||||
# scripts/ in a source checkout
|
||||
cp /usr/share/punktfunk/punktfunk-host-desktop-session.conf \
|
||||
~/.config/systemd/user/punktfunk-host.service.d/desktop-session.conf
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user reenable punktfunk-host
|
||||
systemctl --user restart punktfunk-host
|
||||
```
|
||||
|
||||
Without it, restarting Plasma or GNOME — a crash, a log out and back in, "restart the shell" — leaves
|
||||
the host running against a compositor that no longer exists. It keeps listening and answering, and
|
||||
every session after that fails at capture, which is a confusing way to find out. The drop-in makes a
|
||||
compositor restart a host restart.
|
||||
|
||||
Skip it on the headless/appliance route below (which has its own session unit), and on Sway or
|
||||
Hyprland, which don't hand their session to systemd — start the host from the compositor's config
|
||||
there instead, so it comes and goes with the session.
|
||||
|
||||
## B. A headless, always-on host
|
||||
|
||||
To run with **no monitor and no login** — a machine in a closet that's always ready — you need two
|
||||
|
||||
@@ -18,6 +18,10 @@ opened on.
|
||||
> Reach for a preset when you want a specific experience — a dedicated box you only stream from, a
|
||||
> desktop you also use in person, or a multi-monitor workstation.
|
||||
|
||||
If you'd rather stream a monitor the host **already has** than have punktfunk make one, that's a
|
||||
different setting and it turns most of this page off — see
|
||||
[Stream a real monitor instead](#stream-a-real-monitor-instead) right below.
|
||||
|
||||
> **What's live today:** **keep-alive** (linger, or **forever**), **topology** (extend / primary /
|
||||
> exclusive), **conflict handling**, **per-client identity + persistent scaling** (Windows, KDE/KWin
|
||||
> *and* GNOME/Mutter), and **multi-monitor layout** (several clients as monitors of one desktop) are
|
||||
@@ -25,6 +29,71 @@ opened on.
|
||||
> second. The remaining gaps are noted inline: the Linux `primary` physical-keep *effect*, Sway
|
||||
> `exclusive`, and multi-display for a *single* client (that last is the next stage).
|
||||
|
||||
## Stream a real monitor instead
|
||||
|
||||
Sometimes you don't want a new screen — you want *that* screen. A shop-floor PC on a wall mount, a
|
||||
lab bench machine, a media box whose TV output you'd like to watch from the couch: what matters is
|
||||
seeing the monitor that's already there, exactly as the person in front of it sees it.
|
||||
|
||||
Set **Host → *Virtual displays* → Streamed screen** to one of the listed monitors and punktfunk stops
|
||||
creating a virtual display altogether. It streams that physical monitor instead, and every client
|
||||
sees it at *its* resolution — you're a viewer of a screen, not the owner of your own.
|
||||
|
||||
- The monitor is **never touched**. punktfunk doesn't resize it, move it, disable it or restore it —
|
||||
it only watches. Keep-alive, topology and the multi-monitor layout options above simply don't
|
||||
apply, because there's no display of ours to apply them to.
|
||||
- **The resolution is the monitor's**, not yours. A client asking for a different one is politely
|
||||
told no and scales its own picture — reconfiguring the display someone is sitting at would be
|
||||
rude, and the mid-stream resize machinery is switched off for this reason.
|
||||
- **Every client sees the same screen.** Two clients means two viewers of one monitor, not two
|
||||
desktops.
|
||||
- Naming a monitor that isn't there is a **hard error**, not a fallback: the session fails with
|
||||
`no monitor named "DP-9" — this host has: HDMI-1`. Showing you the wrong screen would be worse
|
||||
than showing you none.
|
||||
- Choosing **Virtual screen (default)** in the same card puts you back on the normal path.
|
||||
|
||||
Supported on **KDE/KWin**, **GNOME/Mutter**, **Sway/wlroots** and **Hyprland** — each through the
|
||||
compositor's own screen-recording API, so there is **no chooser dialog** and nothing to click. That
|
||||
matters most for a host running unattended as a [service](/docs/running-as-a-service): a background
|
||||
`systemd --user` daemon has nobody to answer a permission prompt, so the monitor has to be
|
||||
configuration rather than a question. (A nested gamescope session has no physical monitors of its
|
||||
own, so the setting doesn't appear there.)
|
||||
|
||||
### Naming the monitor from the host
|
||||
|
||||
Monitors are named by **connector** — `HDMI-A-1`, `DP-2`, `eDP-1`. List what this host has:
|
||||
|
||||
```sh
|
||||
punktfunk-host list-monitors
|
||||
```
|
||||
|
||||
```
|
||||
Kwin:
|
||||
HDMI-A-1 1920x1080@60 at +0,+0 scale 1 Dell U2412M [primary]
|
||||
DP-2 2560x1440@144 at +1920,+0 scale 1 ACME 27
|
||||
```
|
||||
|
||||
To pin it **from the host's configuration** instead of the console — the appliance route — set it in
|
||||
[`host.env`](/docs/configuration):
|
||||
|
||||
```sh
|
||||
PUNKTFUNK_CAPTURE_MONITOR=HDMI-A-1
|
||||
```
|
||||
|
||||
The environment variable **wins over the console setting**, deliberately: an operator who declared
|
||||
the answer in the unit's environment shouldn't have it re-aimed by a click. The console shows the
|
||||
Streamed screen card as locked while it's set. Leave it unset on a machine you want to steer from the
|
||||
console.
|
||||
|
||||
Check the whole path — mirror, capture, frames — without a client involved:
|
||||
|
||||
```sh
|
||||
punktfunk-host mirror-test --monitor HDMI-A-1 --seconds 20
|
||||
```
|
||||
|
||||
Compositor screen recording is **damage-driven**: an idle desktop legitimately produces almost no
|
||||
frames, so move the mouse on the host while it runs or a working mirror reads as a stall.
|
||||
|
||||
## Pick a preset
|
||||
|
||||
A preset is the easy way in — select one in the console and you're done. Each expands to a bundle of
|
||||
@@ -179,6 +248,23 @@ shutdown — and only forces the issue after ten seconds of being ignored.
|
||||
> (5 minutes). A display set to **Forever** stays up regardless of what happens to the game — a
|
||||
> pinned display is a deliberate "this box is a game host" choice, and closing a game doesn't undo it.
|
||||
|
||||
### On a gamescope session, the display has the final say
|
||||
|
||||
When a launch gets its **own gamescope** — a dedicated game session, the usual setup on a Steam Deck
|
||||
or a Bazzite couch box — the game runs *inside* the streamed display. So it lives exactly as long as
|
||||
that display does, and **Keep alive decides that, not the setting above**:
|
||||
|
||||
| you disconnect by | what happens to the game |
|
||||
|---|---|
|
||||
| pressing **Stop** (or the console's stop) | the display tears down at once — keep-alive is deliberately skipped for a real stop — and the game goes with it, even on *Leave it running* |
|
||||
| dropping out (network, sleep) | the display lingers for your keep-alive window, then tears down; the game ends with it |
|
||||
| dropping out, keep-alive **Forever** | the display is pinned, so the game genuinely survives — and *Always close it* still ends it when the reconnect window closes |
|
||||
|
||||
So on a gamescope box, "leave the game running after I disconnect" means **keep-alive Forever** (or a
|
||||
window long enough to come back in), not just this setting. On a desktop session — KWin, GNOME, Sway —
|
||||
the game is an ordinary process next to your desktop and none of this applies; the setting above is
|
||||
the whole story.
|
||||
|
||||
### Automation
|
||||
|
||||
The host publishes `game.running` and `game.exited` events (the latter says whether the player quit
|
||||
@@ -235,6 +321,20 @@ landing on a dead stream — and switching between game mode and the KDE / GNOME
|
||||
follows the switch. If a launched game **exits**, a dedicated session ends and returns you to your
|
||||
library; a game mode / desktop session keeps streaming.
|
||||
|
||||
**My keep-alive / topology / layout settings do nothing.** Check whether **Streamed screen** is set
|
||||
to a real monitor. Those options are all about a display punktfunk created; when it's mirroring one
|
||||
of yours there is nothing of ours to keep alive or rearrange. Switch the card back to *Virtual screen
|
||||
(default)* to get them back.
|
||||
|
||||
**The console won't let me change Streamed screen.** `PUNKTFUNK_CAPTURE_MONITOR` is set in this
|
||||
host's [`host.env`](/docs/configuration) and deliberately outranks the console. Unset it (and restart
|
||||
the host) to choose from the console instead.
|
||||
|
||||
**My session fails with "no monitor named …".** The pinned connector isn't among this host's
|
||||
monitors — it was renamed, unplugged, or the host is now in a different session. Run
|
||||
`punktfunk-host list-monitors` on the host to see the real names. punktfunk will not quietly stream a
|
||||
different screen instead.
|
||||
|
||||
**My couch box's TV stayed on the streamed session after I disconnected.** With the **Headless box**
|
||||
preset (keep alive = *forever*), a managed Steam session is held indefinitely so a reconnect resumes
|
||||
instantly — return to game mode on the box (or restart the host) to hand the TV back.
|
||||
|
||||
@@ -36,13 +36,22 @@ Every library entry can now carry **platform, description, developer, publisher,
|
||||
|
||||
## New: install and update the Windows host with winget
|
||||
|
||||
Windows had **no update path at all** — no self-update, no package manager — so keeping a host current meant noticing that a release had happened and re-running an installer by hand. Now, after adding Punktfunk's package source once:
|
||||
Windows had **no update path at all** — no self-update, no package manager — so keeping a host current meant noticing that a release had happened and re-running an installer by hand. Now it is two commands.
|
||||
|
||||
Punktfunk lives in **its own package source**, not the public winget catalogue, so add that first. Once per machine, from an **Administrator** terminal:
|
||||
|
||||
```
|
||||
winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest
|
||||
```
|
||||
|
||||
Then install, and from then on upgrade:
|
||||
|
||||
```
|
||||
winget install unom.PunktfunkHost
|
||||
winget upgrade unom.PunktfunkHost
|
||||
```
|
||||
|
||||
and `winget upgrade` from then on. The source is ours rather than the public catalogue, and it knows about every release, so you can pin an older version or upgrade from one.
|
||||
**If you skip the first command, `winget install unom.PunktfunkHost` will tell you no package was found** — winget only searches the sources it knows about, and Punktfunk is not in the public one. The source knows about every release, so you can also pin an older version with `--version` or upgrade from one.
|
||||
|
||||
A silent install shows you the same disclosures the wizard does — including where the bundled virtual audio device comes from — and takes the same defaults the wizard offers, so installing without a screen doesn't quietly get you a different machine.
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
Update whenever it suits you — the app and the machine you stream from can still be updated one at a time, and everything already paired keeps working. This release is entirely fixes and hardening on top of last release's session⇄game work — nothing new to opt into.
|
||||
|
||||
## Fixed: installing or updating the Windows host
|
||||
|
||||
Three separate reports came in within hours of the last release, all blocking install:
|
||||
|
||||
- **`winget install` could fail outright if you'd ever had another streaming host on the machine** — even one you had since disabled or mostly uninstalled. A leftover, disabled service or an empty folder from an old install was enough to trip the "conflicting host" check and abort. Only a host actually set to start on its own counts now.
|
||||
- **Installing through a package-manager app (like UniGetUI) could fail with a file-path error** before the install wizard ever appeared, because of a broken setting the installer passed itself. Fixed.
|
||||
- **The package source instructions were incomplete.** `winget install unom.PunktfunkHost` says "no package found" until you've pointed winget at Punktfunk's own source, once per machine — the command for that is now spelled out.
|
||||
|
||||
## Fixed: Moonlight compatibility no longer turns on without asking
|
||||
|
||||
A fresh Windows install used to enable Moonlight-app compatibility silently, with no checkbox ever shown — even though that mode pairs over a less secure connection and the host warns about it on every start. It's opt-in now. Existing installs are untouched either way; Punktfunk's own clients were never affected.
|
||||
|
||||
## Fixed: switching into Steam's Game Mode on Linux could black out the machine
|
||||
|
||||
Last release shipped the permission needed to properly take over the screen for Steam's Game Mode — and surfaced a few sharp edges that only show up once that takeover is actually live:
|
||||
|
||||
- **The takeover could kill Punktfunk's own background service a few seconds later**, leaving the display dark with nothing left to bring it back except a physical console or SSH.
|
||||
- **It could also hang waiting on a permission prompt nobody could see**, delaying recovery until well after the stream had already dropped — or act on a leftover marker file from months ago and jump the machine to the desktop for no reason.
|
||||
- **Restarting the host mid-stream** — including a routine update — **could leave the display stuck off**, since nothing told the screen to come back.
|
||||
|
||||
All three are fixed. Recognizing the machine's built-in Steam Game Mode may now need a one-time permission on some distributions; installing or updating normally takes care of it.
|
||||
|
||||
## Fixed: a laptop with its built-in screen turned off stuttered constantly
|
||||
|
||||
On a laptop with two graphics chips and the internal screen switched off — a common setup for streaming from a laptop docked to a TV or monitor — streaming stuttered roughly every two seconds, for the whole session. Punktfunk now recognizes a dark-but-connected laptop screen as the cause and points at the actual fix, instead of pointing at an external monitor that isn't the problem.
|
||||
|
||||
## Fixed: latency creeping up over the course of a fast, high-bitrate stream
|
||||
|
||||
On a fast local network, especially at higher bitrates, latency could climb steadily over a long session instead of staying flat. Several separate causes are fixed at once: the stream's internal clock sync could get starved by a busy connection and let the two ends' clocks drift; a stream mode could put more data on the wire than its bitrate setting actually allowed; a slow-to-decode moment could build up a backlog that took a while to work through instead of catching up immediately; and the one-time startup speed test could be mistaken for real network congestion and permanently cap the stream below what the connection could actually handle.
|
||||
|
||||
## Fixed: missing cover art looked broken
|
||||
|
||||
A library entry with no cover art showed as an empty grey box, which read as something that failed to load. It now shows a controller icon instead, so "no art yet" looks intentional rather than broken.
|
||||
|
||||
## Improved: the Windows app now keeps its own log file
|
||||
|
||||
The desktop app writes its own log to `%LOCALAPPDATA%\punktfunk\logs\client.log` now, instead of only printing to a console window that a normal launch never shows. If you're ever asked to send logs for a problem, this is where they are.
|
||||
|
||||
## Under the hood (for developers)
|
||||
|
||||
- **Nothing changed on the wire or at the API boundary.** Streaming protocol stays at version **2**, the embeddable C ABI at **13**, the Windows virtual-display driver protocol at **6** — 0.18–0.20 hosts and clients keep mixing freely.
|
||||
- **Windows conflict detection** now keys on the competing service's `Start` registry value (0–2 = boot/system/auto only), matching the tray's own dormant-is-harmless logic from last release; install directories no longer count.
|
||||
- **The winget `Log` switch used the literal string `|LOGPATH|`** instead of winget's actual `<LOGPATH>` substitution token, so Inno received an illegal filename on any install that requested a log (UniGetUI does by default). Guarded by a new manifest check.
|
||||
- **`gamestream` installer task lost its default-checked flag**; a silent/unattended install now lands `PUNKTFUNK_HOST_CMD=serve`. `/MERGETASKS=gamestream` opts in. Fresh installs only — `GamestreamParam` already omitted the flag on upgrades.
|
||||
- **The gamescope display-manager takeover** now calls privileged `systemctl` verbs non-interactively (`--no-ask-password`) so a headless polkit prompt can no longer block the stream's own recovery thread; `session_select_requested`'s sentinel baseline is now recorded at takeover time as well as at successful launch, closing a window where a months-old `~/.config/steamos-session-select` write could false-positive a session-select honor; and `punktfunk-host` now handles SIGTERM/SIGINT by restoring the display manager it stopped (20 s grace) before exiting, so a `systemctl --user restart` or package upgrade mid-takeover can't strand the box. The takeover also now ensures the invoking user has systemd lingering enabled before touching the display manager — without it, stopping the DM stops the user's own service manager ~10 s later and takes the host down with it.
|
||||
- **A new `internal_panel` target class** lets the exclusive-topology stall detector name a deactivated eDP/LVDS laptop panel as a disturbance suspect; previously the suspect list was filtered to external physicals only, so the one display responsible on a hybrid laptop reported as none. `EvtIddCxMonitorI2CTransmit/Receive` now answer every DDC/CI probe against the virtual monitor with an immediate `STATUS_NOT_SUPPORTED`, so third-party brightness/monitor tools stop occupying win32k's serialized physical-monitor path on it. The swap-chain drain thread now falls back to `TIME_CRITICAL` priority if MMCSS registration is refused, and raises `IddMinimumVersionRequired` 4 → 10 to use `IddCxSetRealtimeGPUPriority`.
|
||||
- **Clock re-sync** now spaces its probe rounds ~7 ms apart, tracks a running best-RTT guard band instead of a static connect-time floor, and applies the best batch of a rejection streak after three consecutive rejections rather than drifting unbounded.
|
||||
- **A new per-frame `WireBudget`** (shared between encoder backends) tracks the real bitstream-to-wire inflation ratio for datagram-aligned PyroWave sessions and deflates the rate-control target by it, so the configured bitrate pin holds on the wire rather than on the raw codec bitstream.
|
||||
- **The pre-decode frame channel now drains to the newest AU for all-intra (PyroWave) streams**, since those have no reference chain forbidding a mid-stream drop — capping any standing backlog at roughly one frame instead of ratcheting between the coarse jump-to-live thresholds.
|
||||
- **The ABR capacity probe's first post-probe report window is now discarded outright**, rather than measured, so the probe's own deliberate overload can no longer read as session congestion and permanently end slow-start early.
|
||||
- New `display-disturb` dev tool for reproducing DDC/CI and modeset-class display disturbances on demand during stall-immunity testing; not shipped to end users.
|
||||
@@ -680,6 +680,11 @@
|
||||
#define ClockResync_ROUNDS 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Consecutive rejected batches tolerated before the best of them is applied anyway.
|
||||
#define ResyncGuard_MAX_REJECTED_STREAK 3
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`Reconfigure`] (first byte after the magic).
|
||||
#define MSG_RECONFIGURE 1
|
||||
|
||||
@@ -161,6 +161,12 @@ package_punktfunk-host() {
|
||||
install -Dm0644 "$R/scripts/punktfunk-host.service" "$pkgdir/usr/lib/systemd/user/punktfunk-host.service"
|
||||
sed -i 's#%h/punktfunk/target/release/punktfunk-host#/usr/bin/punktfunk-host#' \
|
||||
"$pkgdir/usr/lib/systemd/user/punktfunk-host.service"
|
||||
# Optional drop-in for a DESKTOP-LOGIN host: binds the host to graphical-session.target so a
|
||||
# Plasma/GNOME restart restarts it instead of leaving it on a dead compositor connection. Shipped
|
||||
# under /usr/share (NOT as an active drop-in) because it is wrong for the appliance route — the
|
||||
# operator copies it into ~/.config/systemd/user/punktfunk-host.service.d/ when they want it.
|
||||
install -Dm0644 "$R/scripts/punktfunk-host-desktop-session.conf" \
|
||||
"$pkgdir/usr/share/punktfunk/punktfunk-host-desktop-session.conf"
|
||||
install -Dm0644 "$R/scripts/punktfunk-kde-session.service" "$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service"
|
||||
sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#/usr/share/punktfunk/headless/run-headless-kde.sh#' \
|
||||
"$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service"
|
||||
|
||||
@@ -67,6 +67,12 @@ install -Dm0644 scripts/punktfunk-host.service "$STAGE/usr/lib/systemd/user/
|
||||
# /usr/bin. Rewrite it so a fresh apt install (no hand-rolled unit) starts the installed binary.
|
||||
sed -i 's#%h/punktfunk/target/release/punktfunk-host#/usr/bin/punktfunk-host#' \
|
||||
"$STAGE/usr/lib/systemd/user/punktfunk-host.service"
|
||||
# Optional drop-in for a DESKTOP-LOGIN host: binds the host to graphical-session.target so a
|
||||
# Plasma/GNOME restart restarts it instead of leaving it on a dead compositor connection. Shipped
|
||||
# under /usr/share (NOT as an active drop-in) because it is wrong for the appliance route — the
|
||||
# operator copies it into ~/.config/systemd/user/punktfunk-host.service.d/ when they want it.
|
||||
install -Dm0644 scripts/punktfunk-host-desktop-session.conf \
|
||||
"$STAGE/usr/share/punktfunk-host/punktfunk-host-desktop-session.conf"
|
||||
# Optional headless KWin session unit (the kwin --virtual appliance), as the RPM/Arch ship.
|
||||
# Repoint its ExecStart from the dev source tree to the packaged script. NOT enabled by default.
|
||||
install -Dm0644 scripts/punktfunk-kde-session.service "$STAGE/usr/lib/systemd/user/punktfunk-kde-session.service"
|
||||
|
||||
@@ -284,6 +284,11 @@ install -Dm0644 scripts/punktfunk-host.service %{buildroot}%{_userunitdir}/punkt
|
||||
# The source unit's ExecStart points at the dev source tree; a packaged install has the binary at
|
||||
# %{_bindir}. Rewrite it so a fresh install (no hand-rolled unit) starts the installed binary.
|
||||
sed -i 's#%h/punktfunk/target/release/punktfunk-host#%{_bindir}/punktfunk-host#' %{buildroot}%{_userunitdir}/punktfunk-host.service
|
||||
# Optional drop-in for a DESKTOP-LOGIN host: binds the host to graphical-session.target so a
|
||||
# Plasma/GNOME restart restarts it instead of leaving it on a dead compositor connection. Shipped
|
||||
# under %{_datadir}/%{name} (NOT as an active drop-in) because it is wrong for the appliance route —
|
||||
# the operator copies it into ~/.config/systemd/user/punktfunk-host.service.d/ when they want it.
|
||||
install -Dm0644 scripts/punktfunk-host-desktop-session.conf %{buildroot}%{_datadir}/%{name}/punktfunk-host-desktop-session.conf
|
||||
|
||||
# Optional headless KDE session unit (the kwin streaming appliance): brings up `kwin --virtual` on
|
||||
# wayland-kde via the packaged run-headless-kde.sh, so the host's kwin backend has a session whose
|
||||
|
||||
@@ -60,10 +60,14 @@ parse breakage that silently failed installs on non-English boxes.
|
||||
**`PunktfunkWeb`** scheduled task (boot, SYSTEM, restart-on-failure → `web-run.cmd` → `bun` on
|
||||
`:47992`), opens TCP 47992, and starts it. It proxies the host's loopback mgmt API with the host's
|
||||
own `%ProgramData%\punktfunk\mgmt-token`.
|
||||
- **GameStream (Moonlight) compatibility is a wizard task** (checked by default): the choice is passed
|
||||
to `service install --gamestream=on|off`, which writes `PUNKTFUNK_HOST_CMD=serve --gamestream` (or
|
||||
`serve`, the secure native-only host) into `host.env`. Upgrade-safe: a hand-customized
|
||||
`PUNKTFUNK_HOST_CMD` is never overwritten.
|
||||
- **GameStream (Moonlight) compatibility is a wizard task** (**unchecked** by default — it pairs over
|
||||
plain HTTP, so it is opt-in like the Public-firewall task): the choice is passed to
|
||||
`service install --gamestream=on|off`, which writes `PUNKTFUNK_HOST_CMD=serve --gamestream` (or
|
||||
`serve`, the secure native-only host) into `host.env`. Unattended, add it with
|
||||
`/MERGETASKS=gamestream`. Upgrade-safe: a hand-customized `PUNKTFUNK_HOST_CMD` is never
|
||||
overwritten, and on an upgrade the task is inert entirely (the flag is omitted, so `host.env`
|
||||
keeps whatever it already says) — change an existing host with
|
||||
`punktfunk-host service install --gamestream=on|off` plus a service restart.
|
||||
- **Branded, modern wizard**: `WizardStyle=modern dynamic windows11` (Inno ≥ 6.6 — Windows-11-style
|
||||
controls following the system light/dark theme; pre-6.6 compilers fall back to plain `modern`), with
|
||||
the punktfunk lens mark on the side panel / header tile and a multi-size `punktfunk.ico`
|
||||
|
||||
@@ -375,3 +375,30 @@ pub unsafe extern "C" fn device_io_control(
|
||||
// SAFETY: `request` is the framework-provided WDFREQUEST; `control::dispatch` completes it exactly once.
|
||||
unsafe { crate::control::dispatch(request, ioctl_code) };
|
||||
}
|
||||
|
||||
/// DDC/CI transmit toward the virtual monitor — refuse INSTANTLY (stall-immunity: DDC fail-fast).
|
||||
///
|
||||
/// In exclusive topology the virtual display is the ONLY monitor on the desktop, so
|
||||
/// monitor-control software (the Twinkle Tray / PowerToys PowerDisplay / Monitorian class) aims
|
||||
/// its entire DDC traffic — brightness polls, capabilities-string requests — at THIS monitor.
|
||||
/// There is no bus and no sink here; the only wrong answer is a slow one (a timeout-shaped
|
||||
/// failure occupies win32k's physical-monitor path, serialized per monitor, for its full
|
||||
/// duration). Registering the pair pins every probe's cost to one immediate
|
||||
/// `STATUS_NOT_SUPPORTED`. EDID needs no equivalent: the OS serves descriptor queries from the
|
||||
/// blob supplied at monitor creation without calling the driver.
|
||||
pub unsafe extern "C" fn monitor_i2c_transmit(
|
||||
_monitor: iddcx::IDDCX_MONITOR,
|
||||
_p_in: *const iddcx::IDARG_IN_I2C_TRANSMIT,
|
||||
) -> NTSTATUS {
|
||||
crate::STATUS_NOT_SUPPORTED
|
||||
}
|
||||
|
||||
/// DDC/CI receive from the virtual monitor — same contract as [`monitor_i2c_transmit`]. (The
|
||||
/// receive DDI has no out-arg at the callback — data would flow back through an async completion;
|
||||
/// a synchronous failure return refuses the whole transaction, which is exactly the point.)
|
||||
pub unsafe extern "C" fn monitor_i2c_receive(
|
||||
_monitor: iddcx::IDDCX_MONITOR,
|
||||
_p_in: *const iddcx::IDARG_IN_I2C_RECEIVE,
|
||||
) -> NTSTATUS {
|
||||
crate::STATUS_NOT_SUPPORTED
|
||||
}
|
||||
|
||||
@@ -89,6 +89,11 @@ extern "C" fn driver_add(_driver: WDFDRIVER, mut init: PWDFDEVICE_INIT) -> NTSTA
|
||||
cfg.EvtIddCxMonitorSetGammaRamp = Some(callbacks::set_gamma_ramp);
|
||||
cfg.EvtIddCxMonitorAssignSwapChain = Some(callbacks::assign_swap_chain);
|
||||
cfg.EvtIddCxMonitorUnassignSwapChain = Some(callbacks::unassign_swap_chain);
|
||||
// DDC fail-fast (stall-immunity): monitor-control software polls the virtual monitor's DDC —
|
||||
// in exclusive topology it is the ONLY monitor. These answer every probe with an immediate
|
||||
// STATUS_NOT_SUPPORTED instead of whatever slow path an unregistered interface takes.
|
||||
cfg.EvtIddCxMonitorI2CTransmit = Some(callbacks::monitor_i2c_transmit);
|
||||
cfg.EvtIddCxMonitorI2CReceive = Some(callbacks::monitor_i2c_receive);
|
||||
cfg.EvtIddCxDeviceIoControl = Some(callbacks::device_io_control);
|
||||
|
||||
// SAFETY: init is the framework device-init; cfg is fully populated + sized. (Links IddCxStub.)
|
||||
|
||||
@@ -35,12 +35,22 @@ use wdk_sys::NTSTATUS;
|
||||
// NTSTATUS codes the driver returns (wdk-sys doesn't surface all of these as constants).
|
||||
pub(crate) const STATUS_SUCCESS: NTSTATUS = 0;
|
||||
pub(crate) const STATUS_NOT_IMPLEMENTED: NTSTATUS = 0xC000_0002u32 as NTSTATUS;
|
||||
pub(crate) const STATUS_NOT_SUPPORTED: NTSTATUS = 0xC000_00BBu32 as NTSTATUS;
|
||||
pub(crate) const STATUS_NOT_FOUND: NTSTATUS = 0xC000_0225u32 as NTSTATUS;
|
||||
pub(crate) const STATUS_INVALID_PARAMETER: NTSTATUS = 0xC000_000Du32 as NTSTATUS;
|
||||
pub(crate) const STATUS_BUFFER_TOO_SMALL: NTSTATUS = 0xC000_0023u32 as NTSTATUS;
|
||||
|
||||
/// IddCx (stub mode) requires the driver to export the minimum IddCx framework version it needs — the
|
||||
/// `#ifndef IDD_STUB` branch of `IddCxFuncEnum.h` that normally emits it is compiled out under
|
||||
/// `IDD_STUB`. `4` matches the proven `wdf-umdf` oracle.
|
||||
/// `IDD_STUB`.
|
||||
///
|
||||
/// `10` = IddCx 1.10, the TRUTHFUL floor: the drain loop calls
|
||||
/// `IddCxSwapChainReleaseAndAcquireBuffer2` (1.10) unconditionally and the swap-chain worker uses
|
||||
/// `IddCxSetRealtimeGPUPriority` (1.9); the product floor is already Windows 11 22H2 / build 22621,
|
||||
/// whose framework is 1.10 (the installer gate — `MinVersion=10.0.22621` in punktfunk-host.iss —
|
||||
/// exists precisely for this driver). The oracle's historical `4` predated that floor and would let
|
||||
/// the driver load against a SHORT `IddFunctions` table, where dispatching any post-1.4 DDI reads
|
||||
/// past the populated entries. With `10`, an older framework fails the bind cleanly (driver doesn't
|
||||
/// load) instead of limping into undefined dispatch.
|
||||
#[unsafe(no_mangle)]
|
||||
pub static IddMinimumVersionRequired: wdk_sys::ULONG = 4;
|
||||
pub static IddMinimumVersionRequired: wdk_sys::ULONG = 10;
|
||||
|
||||
@@ -29,8 +29,8 @@ use std::{
|
||||
};
|
||||
|
||||
use wdk_sys::iddcx::{
|
||||
IDARG_IN_RELEASEANDACQUIREBUFFER2, IDARG_IN_SWAPCHAINSETDEVICE,
|
||||
IDARG_OUT_RELEASEANDACQUIREBUFFER2, IDDCX_SWAPCHAIN,
|
||||
IDARG_IN_RELEASEANDACQUIREBUFFER2, IDARG_IN_SETREALTIMEGPUPRIORITY,
|
||||
IDARG_IN_SWAPCHAINSETDEVICE, IDARG_OUT_RELEASEANDACQUIREBUFFER2, IDDCX_SWAPCHAIN,
|
||||
};
|
||||
// `HANDLE` is the shared wdk-sys typedef (`crate::types`) re-used by the iddcx bindings — take it from
|
||||
// the crate root, which is guaranteed to export it (the iddcx module only re-exports it if bindgen
|
||||
@@ -44,7 +44,8 @@ use windows::{
|
||||
Dxgi::{IDXGIDevice, IDXGIResource},
|
||||
},
|
||||
System::Threading::{
|
||||
AvRevertMmThreadCharacteristics, AvSetMmThreadCharacteristicsW, WaitForSingleObject,
|
||||
AvRevertMmThreadCharacteristics, AvSetMmThreadCharacteristicsW, GetCurrentThread,
|
||||
SetThreadPriority, THREAD_PRIORITY_TIME_CRITICAL, WaitForSingleObject,
|
||||
},
|
||||
},
|
||||
core::{Interface, w},
|
||||
@@ -125,12 +126,25 @@ impl SwapChainProcessor {
|
||||
// MMCSS can fail under the restricted WUDFHost token ('Distribution' task unregistered /
|
||||
// service unavailable). The MS sample CONTINUES unprioritized — never abort: returning
|
||||
// here would leave the assigned swap-chain undrained (the monitor stalls, DWM blocks on
|
||||
// it) and leak the WDF swap-chain object until device teardown.
|
||||
// it) and leak the WDF swap-chain object until device teardown. But "unprioritized" is
|
||||
// not acceptable either: this thread is the whole display's frame pump, and at normal
|
||||
// priority a display-stack disturbance (DDC/HPD servicing DPC pressure, poller-software
|
||||
// storms) can starve it into multi-hundred-ms delivery holes. Fall back to
|
||||
// TIME_CRITICAL — the highest band available without the realtime priority class, and
|
||||
// the closest to what MMCSS 'Distribution' would have granted. The thread spends its
|
||||
// life blocked on the surface-available event / keyed mutex, so it cannot starve others.
|
||||
let av_handle = match res {
|
||||
Ok(h) => Some(h),
|
||||
Err(e) => {
|
||||
// SAFETY: plain FFI; GetCurrentThread returns a pseudo-handle (never fails,
|
||||
// nothing to close), SetThreadPriority on it affects only this thread.
|
||||
let fallback = unsafe {
|
||||
SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_TIME_CRITICAL)
|
||||
};
|
||||
dbglog!(
|
||||
"[pf-vd] swap-chain: MMCSS prioritization failed ({e:?}) — continuing unprioritized"
|
||||
"[pf-vd] swap-chain: MMCSS prioritization failed ({e:?}) — fell back to \
|
||||
TIME_CRITICAL thread priority (ok={})",
|
||||
fallback.is_ok()
|
||||
);
|
||||
None
|
||||
}
|
||||
@@ -229,6 +243,29 @@ impl SwapChainProcessor {
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
// IddCx 1.9 realtime GPU scheduling priority for the processing device (stall-immunity
|
||||
// program, branch-2 hardening): swap-chain buffer processing outruns ordinary GPU
|
||||
// contention — "higher priority than any regular application can set". The slot is
|
||||
// guaranteed populated (`IddMinimumVersionRequired = 10`, lib.rs); the DDI itself may
|
||||
// still decline (e.g. E_NOTIMPL on pre-WDDM-3.0 hardware) — best-effort, never fatal.
|
||||
// Called while our borrowed device reference is still alive; IddCx uses it synchronously.
|
||||
if set_ok {
|
||||
let mut rt = pod_init!(IDARG_IN_SETREALTIMEGPUPRIORITY);
|
||||
rt.pDevice = dxgi_device.as_raw().cast();
|
||||
// SAFETY: driver is loaded; `swap_chain` is the live assigned swap-chain whose device
|
||||
// bind just succeeded; `rt.pDevice` is that same bound DXGI device, alive across the
|
||||
// synchronous call; `rt` points to valid local storage.
|
||||
let hr = unsafe { wdk_iddcx::IddCxSetRealtimeGPUPriority(swap_chain, &rt) };
|
||||
if hr_success(hr) {
|
||||
dbglog!(
|
||||
"[pf-vd] swap-chain: processing device raised to REALTIME GPU priority (target={target_id})"
|
||||
);
|
||||
} else {
|
||||
dbglog!(
|
||||
"[pf-vd] swap-chain: realtime GPU priority declined ({hr:#x}) — normal scheduling (target={target_id})"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Release our borrowed device reference — IddCx holds its own now, or we gave up. (Explicit drop
|
||||
// so NLL can't release it mid-loop while the swap-chain still references the raw ptr.)
|
||||
drop(dxgi_device);
|
||||
|
||||
@@ -200,3 +200,20 @@ iddcx_ddi!(
|
||||
IddCxSwapChainFinishedProcessingFrame(swap_chain: iddcx::IDDCX_SWAPCHAIN)
|
||||
@ IddCxSwapChainFinishedProcessingFrameTableIndex as PFN_IDDCXSWAPCHAINFINISHEDPROCESSINGFRAME
|
||||
);
|
||||
|
||||
iddcx_ddi!(
|
||||
/// Raise the swap-chain's processing D3D device to realtime GPU scheduling priority — "higher
|
||||
/// than any regular application can set" (IddCx 1.9) — so buffer processing outruns ordinary
|
||||
/// GPU contention. It does NOT help against adapter-wide display servicing (modeset-class DDIs
|
||||
/// idle the hardware outright); it defends the contention case only. Best-effort at the call
|
||||
/// site: the DDI itself may decline (e.g. E_NOTIMPL on WDDM < 3.0 hardware).
|
||||
///
|
||||
/// Table-slot availability rests on the driver's `IddMinimumVersionRequired = 10` export
|
||||
/// (pf-vdisplay lib.rs): the loader refuses to bind a framework older than IddCx 1.10, so
|
||||
/// every slot of our compiled 1.10 surface — this 1.9 one included — is populated wherever the
|
||||
/// driver runs at all.
|
||||
IddCxSetRealtimeGPUPriority(
|
||||
swap_chain: iddcx::IDDCX_SWAPCHAIN,
|
||||
in_args: *const iddcx::IDARG_IN_SETREALTIMEGPUPRIORITY,
|
||||
) @ IddCxSetRealtimeGPUPriorityTableIndex as PFN_IDDCXSETREALTIMEGPUPRIORITY
|
||||
);
|
||||
|
||||
@@ -160,8 +160,20 @@ Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan
|
||||
#endif
|
||||
; Host-config choice, applied via `service install --gamestream=on|off` (writes PUNKTFUNK_HOST_CMD
|
||||
; in host.env; a hand-customized value is left alone). Checked = the Moonlight-compatible unified
|
||||
; host (the common Windows setup); unchecked = the secure native-only host (Punktfunk clients only).
|
||||
Name: "gamestream"; Description: "Enable GameStream (Moonlight) compatibility - lets stock Moonlight clients connect (uses legacy plain-HTTP pairing; for trusted LANs)"
|
||||
; host; unchecked (DEFAULT) = the secure native-only host (Punktfunk clients only).
|
||||
;
|
||||
; OPT-IN, like allowpublicfw below and for the same reason: the host itself WARNs on every start
|
||||
; that this plane pairs over plain HTTP and its legacy control encryption can reuse GCM nonces
|
||||
; (security-review #5/#9), so an on-path LAN attacker could MITM pairing or recover input. A
|
||||
; default-on security downgrade cannot be squared with that warning - least of all on the silent
|
||||
; path, where the wizard never appears and 1839d756 makes an unattended install take these very
|
||||
; defaults. Reported by a user who found the warning in their log and had never been shown a
|
||||
; choice, because they installed through winget.
|
||||
;
|
||||
; Turning it on unattended is `/MERGETASKS="gamestream"`; on an UPGRADE this task is inert either
|
||||
; way (GamestreamParam omits the flag unless FreshHostInstall), so an existing host keeps whatever
|
||||
; host.env already says - changing it afterwards is `service install --gamestream=on|off`.
|
||||
Name: "gamestream"; Description: "Enable GameStream (Moonlight) compatibility - lets stock Moonlight clients connect (uses legacy plain-HTTP pairing; for trusted LANs)"; Flags: unchecked
|
||||
; Firewall scope, forwarded as `--allow-public-network` to `service install` / `web setup`. Unchecked
|
||||
; (default) = accept connections on Private + Domain networks only (the trusted-network profiles
|
||||
; punktfunk is meant for). Check ONLY for a network you trust that Windows classifies as Public (e.g.
|
||||
@@ -354,15 +366,31 @@ begin
|
||||
Result := ExpandConstant('{commonappdata}\punktfunk\host.env');
|
||||
end;
|
||||
|
||||
{ True if another Moonlight-compatible streaming host is installed - by its SCM service key or its
|
||||
Program Files directory. Sunshine and its forks all register a "<Name>Service" and install under
|
||||
Program Files, so a registry + directory probe catches them without enumerating processes (which
|
||||
pure Pascal can't do cleanly). }
|
||||
function StreamHostPresent(SvcKey, DirName: String): Boolean;
|
||||
{ True if another Moonlight-compatible streaming host is not merely present but will actually RUN.
|
||||
Sunshine and its forks register a "<Name>Service"; `Start` is that service's start type
|
||||
(REG_DWORD): 0 boot, 1 system, 2 automatic, 3 manual, 4 disabled. Only 0-2 come up on their own,
|
||||
and only a host that comes up can take the GameStream ports or load a second virtual-display
|
||||
driver - which is the entire content of the warning below.
|
||||
|
||||
DELIBERATELY NARROWER than it was. The old probe also counted the service key existing at ANY
|
||||
start type, plus a bare Program Files\<Name> directory - so a disabled service, or a leftover folder
|
||||
from an uninstall, read as a live conflict. Combined with the silent-install default of IDNO that
|
||||
aborted setup, and a field report followed within hours of 0.20.0: `winget install
|
||||
unom.PunktfunkHost` failed with exit code 1 (0x8A150006) on a box whose Sunshine was not running.
|
||||
Nothing about a dormant install can clash, and the tray reached the same conclusion in this same
|
||||
release (3e782852 dropped its always-on warning over a merely-INSTALLED Sunshine as a false
|
||||
alarm) - the two surfaces now agree.
|
||||
|
||||
A host running as a plain user process rather than a service is not detected here, by choice:
|
||||
that cannot be seen from pure Pascal, it is a runtime condition rather than an install-time one,
|
||||
and the host already reports it where it belongs (the `punktfunk::detect` startup warning, the
|
||||
`detect-conflicts` subcommand, and /api/v1/local/summary). }
|
||||
function StreamHostEnabled(SvcKey: String): Boolean;
|
||||
var
|
||||
StartType: Cardinal;
|
||||
begin
|
||||
Result := RegKeyExists(HKLM, 'SYSTEM\CurrentControlSet\Services\' + SvcKey)
|
||||
or DirExists(ExpandConstant('{commonpf}\' + DirName))
|
||||
or DirExists(ExpandConstant('{commonpf32}\' + DirName));
|
||||
Result := RegQueryDWordValue(HKLM, 'SYSTEM\CurrentControlSet\Services\' + SvcKey, 'Start', StartType)
|
||||
and (StartType <= 2);
|
||||
end;
|
||||
|
||||
{ Runs before any wizard page - the earliest point we can warn. Detect a conflicting host and let
|
||||
@@ -375,11 +403,11 @@ begin
|
||||
{ Record the fresh-vs-upgrade verdict while host.env still reflects the PREVIOUS run. }
|
||||
FreshHostInstall := not FileExists(HostEnvPath);
|
||||
Found := '';
|
||||
if StreamHostPresent('SunshineService', 'Sunshine') then Found := Found + ' - Sunshine' + #13#10;
|
||||
if StreamHostPresent('ApolloService', 'Apollo') then Found := Found + ' - Apollo' + #13#10;
|
||||
if StreamHostPresent('VibeshineService', 'Vibeshine') then Found := Found + ' - Vibeshine' + #13#10;
|
||||
if StreamHostPresent('VibepolloService', 'Vibepollo') then Found := Found + ' - Vibepollo' + #13#10;
|
||||
if StreamHostPresent('LuminalShineService', 'LuminalShine') then Found := Found + ' - LuminalShine' + #13#10;
|
||||
if StreamHostEnabled('SunshineService') then Found := Found + ' - Sunshine' + #13#10;
|
||||
if StreamHostEnabled('ApolloService') then Found := Found + ' - Apollo' + #13#10;
|
||||
if StreamHostEnabled('VibeshineService') then Found := Found + ' - Vibeshine' + #13#10;
|
||||
if StreamHostEnabled('VibepolloService') then Found := Found + ' - Vibepollo' + #13#10;
|
||||
if StreamHostEnabled('LuminalShineService') then Found := Found + ' - LuminalShine' + #13#10;
|
||||
if Found <> '' then
|
||||
{ SuppressibleMsgBox, NOT MsgBox: a plain MsgBox ignores /SUPPRESSMSGBOXES and displays even
|
||||
under /VERYSILENT - i.e. an unattended install (winget) would block on a modal dialog with no
|
||||
@@ -387,12 +415,15 @@ begin
|
||||
install onto a box that already runs Sunshine/Apollo ABORTS (Setup exits non-zero) instead of
|
||||
proceeding into the unsupported dual-host state the message describes. }
|
||||
Result := SuppressibleMsgBox(
|
||||
'Another game-streaming host is already installed on this PC:' + #13#10#13#10 + Found + #13#10 +
|
||||
{ NB: keep #13#10 off the START of a line - ISPP reads a leading '#' as a preprocessor
|
||||
directive and aborts the compile with "Unknown preprocessor directive". }
|
||||
'Another game-streaming host is installed on this PC and set to start automatically:' + #13#10#13#10 + Found + #13#10 +
|
||||
'Running Punktfunk alongside Sunshine / Apollo / other Moonlight-compatible hosts is NOT ' +
|
||||
'supported. They bind the same GameStream network ports (47984, 47989, 47998-48010) and ' +
|
||||
'install a conflicting virtual-display driver, which causes pairing failures, "address ' +
|
||||
'already in use" errors and capture glitches.' + #13#10#13#10 +
|
||||
'Stop and uninstall the other host before using Punktfunk.' + #13#10#13#10 +
|
||||
'Stop and disable its service (or uninstall it) before using Punktfunk. A host that is ' +
|
||||
'installed but disabled does not clash and is not reported here.' + #13#10#13#10 +
|
||||
'Continue with the installation anyway?',
|
||||
mbConfirmation, MB_YESNO or MB_DEFBUTTON2, IDNO) = IDYES;
|
||||
end;
|
||||
|
||||
@@ -100,6 +100,16 @@ const post = (p, body) =>
|
||||
check("manifest: installer has URL + sha256", !!inst?.InstallerUrl && /^[0-9A-F]{64}$/i.test(inst?.InstallerSha256 ?? ""));
|
||||
check("manifest: installer-level fields folded into the entry", inst?.InstallerType === "inno" && inst?.Scope === "machine");
|
||||
check("manifest: ProductCode preserved for correlation", !!inst?.ProductCode || !!v?.ProductCodes?.length);
|
||||
// A Log switch is only useful if winget SUBSTITUTES the path. <LOGPATH> is the one token it
|
||||
// replaces; anything else is passed through verbatim, and `|LOGPATH|` shipped in 0.20.0 — Inno
|
||||
// then rejected `|` as a filename and aborted with "Error creating log file", in EVERY install
|
||||
// mode, for any caller that requests a log (UniGetUI does by default). Reported from the field.
|
||||
const log = inst?.InstallerSwitches?.Log;
|
||||
check(
|
||||
"manifest: Log switch uses winget's <LOGPATH> token, if present",
|
||||
log === undefined || (log.includes("<LOGPATH>") && !/\|[A-Z]+\|/.test(log)),
|
||||
`got ${JSON.stringify(log)}`,
|
||||
);
|
||||
}
|
||||
{
|
||||
const res = await get("packageManifests/UNOM.PUNKTFUNKHOST");
|
||||
|
||||
@@ -29,13 +29,23 @@ InstallerSwitches:
|
||||
# disclosures the wizard puts on screen are carried by Agreements/InstallationNotes in the
|
||||
# locale manifest instead.
|
||||
#
|
||||
# To opt out of individual tasks, callers use Inno's ! negation via --override, e.g.
|
||||
# winget install unom.PunktfunkHost --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!gamestream"
|
||||
# To CHANGE individual tasks, callers pass Inno's /MERGETASKS via --override: a bare name adds a
|
||||
# task, a `!` prefix removes one. GameStream is opt-in (its plane pairs over plain HTTP), so
|
||||
# enabling it unattended is the additive form:
|
||||
# winget install unom.PunktfunkHost --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=gamestream"
|
||||
# and dropping a default-on task is the negated form, e.g. /MERGETASKS=!trayicon
|
||||
# Task names: installdriver, installgamepad, installaudiocable, installhdrlayer,
|
||||
# gamestream, allowpublicfw, startservice, trayicon
|
||||
Silent: /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-
|
||||
SilentWithProgress: /SILENT /SUPPRESSMSGBOXES /NORESTART /SP-
|
||||
Log: /LOG="|LOGPATH|"
|
||||
# <LOGPATH> is winget's OWN token and the only spelling it substitutes (see the schema this file
|
||||
# declares: "…<LOGPATH> token can be included in the switch value so that winget will replace the
|
||||
# token with user provided path"). It was written |LOGPATH| — not a token, so winget passed the
|
||||
# literal string through and Inno rejected it: `|` is not legal in a Windows filename, giving
|
||||
# "Error creating log file: The filename, directory name, or volume label syntax is incorrect."
|
||||
# That aborted the install in EVERY mode, interactive included, for any caller that asks for a
|
||||
# log — UniGetUI does so by default, which is how it was reported.
|
||||
Log: /LOG="<LOGPATH>"
|
||||
|
||||
# Inno writes its ARP entry under <AppId>_is1; this is what correlates an installed host with the
|
||||
# package for `winget list` / `winget upgrade`. Must track AppId in the .iss.
|
||||
|
||||
@@ -46,13 +46,14 @@ Agreements:
|
||||
redistributed under VB-Audio's bundling grant; the full notice is installed to
|
||||
%ProgramFiles%\punktfunk\licenses\VB-CABLE-NOTICE.txt.
|
||||
AgreementUrl: https://vb-audio.com/Cable/
|
||||
- AgreementLabel: GameStream (Moonlight) compatibility is enabled by default
|
||||
- AgreementLabel: GameStream (Moonlight) compatibility is OFF by default
|
||||
Agreement: >-
|
||||
The default install enables GameStream so stock Moonlight clients can connect. GameStream uses
|
||||
legacy plain-HTTP pairing and is intended for trusted LANs only. To install without it, use
|
||||
--override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!gamestream", or run
|
||||
--interactive and clear the GameStream checkbox. It can also be changed later via
|
||||
PUNKTFUNK_HOST_CMD in %ProgramData%\punktfunk\host.env.
|
||||
Punktfunk's own clients work out of the box. Support for stock Moonlight clients is a separate,
|
||||
opt-in plane, because it pairs over legacy plain HTTP and is intended for trusted LANs only. To
|
||||
install WITH it, use --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-
|
||||
/MERGETASKS=gamestream", or run --interactive and tick the GameStream checkbox. It can also be
|
||||
turned on later with `punktfunk-host service install --gamestream=on` (or via
|
||||
PUNKTFUNK_HOST_CMD in %ProgramData%\punktfunk\host.env), followed by a service restart.
|
||||
AgreementUrl: https://docs.punktfunk.unom.io/docs/windows-host
|
||||
|
||||
# Displayed after the install completes. The console password is generated per install, so it can
|
||||
|
||||
+17
-1
@@ -32,8 +32,24 @@ case "${1-}" in
|
||||
systemctl reset-failed "$dm" 2>/dev/null || :
|
||||
exec systemctl restart "$dm"
|
||||
;;
|
||||
linger)
|
||||
# Keep the caller's own `systemd --user` manager alive across the stop. Stopping the display
|
||||
# manager ends the user's last login session, and logind then stops user@<uid>.service after
|
||||
# UserStopDelaySec (10s by default) — which takes the host that asked for the takeover with
|
||||
# it, so nothing is left to restart the display manager and the box stays dark. Lingering is
|
||||
# what breaks that dependency (the setup docs already ask for it).
|
||||
#
|
||||
# The user is NEVER caller-named: PKEXEC_UID is set by pkexec from the authenticated caller,
|
||||
# so this grant enables lingering for that caller alone.
|
||||
uid=${PKEXEC_UID:-}
|
||||
[ -n "$uid" ] || {
|
||||
echo "pf-dm-helper: no PKEXEC_UID in the environment — refusing to guess a user" >&2
|
||||
exit 1
|
||||
}
|
||||
exec loginctl enable-linger "$uid"
|
||||
;;
|
||||
*)
|
||||
echo "usage: pf-dm-helper stop|restore" >&2
|
||||
echo "usage: pf-dm-helper stop|restore|linger" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# systemd USER drop-in: bind the host to the DESKTOP LOGIN session's lifetime.
|
||||
#
|
||||
# Install (on top of punktfunk-host.service):
|
||||
# mkdir -p ~/.config/systemd/user/punktfunk-host.service.d
|
||||
# cp scripts/punktfunk-host-desktop-session.conf \
|
||||
# ~/.config/systemd/user/punktfunk-host.service.d/desktop-session.conf
|
||||
# systemctl --user daemon-reload
|
||||
# systemctl --user reenable punktfunk-host # picks up the [Install] below
|
||||
# systemctl --user restart punktfunk-host
|
||||
#
|
||||
# WHY: the shipped unit's `PartOf=punktfunk-kde-session.service` covers the APPLIANCE route, where we
|
||||
# start the compositor ourselves. A host on a machine somebody logs into has no such unit — so when
|
||||
# Plasma or GNOME restarts (a crash, a session logout/login, "restart the shell"), the daemon keeps
|
||||
# running while holding a Wayland socket and a portal D-Bus connection that both died with the old
|
||||
# compositor. It cannot recover those in-process. It is a quiet failure — the host still listens, still
|
||||
# answers, and every session it then serves fails at capture. A host that mirrors a monitor
|
||||
# (design/per-monitor-portal-capture.md) sits idle for days between sessions, which is exactly the
|
||||
# shape that gets discovered at the worst moment.
|
||||
#
|
||||
# `graphical-session.target` is the session's own lifetime, reached by GNOME and Plasma under systemd
|
||||
# session management. `PartOf=` stops us when it goes down; the `WantedBy=` below starts us again when
|
||||
# the new session brings it back up. So a compositor restart becomes a host restart, with a fresh
|
||||
# connection to the compositor that is actually there.
|
||||
#
|
||||
# NOT for the appliance/headless route (a pinned PUNKTFUNK_COMPOSITOR, punktfunk-kde-session.service,
|
||||
# a gamescope box): those have their own session unit and may never reach graphical-session.target at
|
||||
# all, and this drop-in would leave the host permanently stopped.
|
||||
#
|
||||
# Sway / Hyprland / any session NOT managed by systemd never reaches graphical-session.target either.
|
||||
# There the drop-in is harmless but does nothing; start the host from the compositor's own config
|
||||
# instead (`exec systemctl --user start punktfunk-host` after `systemctl --user import-environment`),
|
||||
# so it dies and comes back with the session.
|
||||
|
||||
[Unit]
|
||||
After=graphical-session.target
|
||||
PartOf=graphical-session.target
|
||||
|
||||
[Install]
|
||||
# In ADDITION to the base unit's WantedBy=default.target, so the host still comes up at login even
|
||||
# before the graphical session is ready — it listens without touching the compositor and only opens
|
||||
# one per client connect, so an early start costs nothing.
|
||||
WantedBy=graphical-session.target
|
||||
@@ -8,6 +8,18 @@
|
||||
# cp scripts/host.env.example ~/.config/punktfunk/host.env # defaults are right for a desktop
|
||||
# systemctl --user daemon-reload && systemctl --user enable --now punktfunk-host
|
||||
#
|
||||
# On that desktop-login route, ALSO install scripts/punktfunk-host-desktop-session.conf as a drop-in:
|
||||
# it binds the host to graphical-session.target, so a Plasma/GNOME restart restarts the host instead
|
||||
# of leaving it holding a dead Wayland socket + portal connection (the PartOf= below only covers the
|
||||
# appliance route, where we start the compositor ourselves). The drop-in's header has the commands.
|
||||
#
|
||||
# The host does NOT need the login shell's environment: it detects the live session itself per
|
||||
# connect and derives WAYLAND_DISPLAY, XDG_RUNTIME_DIR, DBUS_SESSION_BUS_ADDRESS, XDG_CURRENT_DESKTOP,
|
||||
# HYPRLAND_INSTANCE_SIGNATURE and SWAYSOCK from the running compositor (vdisplay::apply_session_env).
|
||||
# So host.env carries policy, not session plumbing, and `systemctl --user import-environment` is not
|
||||
# a prerequisite — the host pushes the live values into the manager env itself when a session switch
|
||||
# needs the portal to re-read them.
|
||||
#
|
||||
# Self-contained boot appliance (no login, no manual steps after boot). These routes PIN the
|
||||
# backend via PUNKTFUNK_COMPOSITOR — correct for a dedicated single-session box, but it turns off
|
||||
# live-session auto-detection, so never do it on a desktop that switches sessions (Game Mode etc.):
|
||||
|
||||
@@ -33,11 +33,15 @@ PUNKTFUNK_SECURE_DDA=1
|
||||
# Log level (info | debug | trace). Logs land in %ProgramData%\punktfunk\logs\.
|
||||
RUST_LOG=info
|
||||
|
||||
# The host subcommand the service launches. Default: `serve --gamestream` (native punktfunk/1 host
|
||||
# ALWAYS on + the GameStream/Moonlight-compat planes). Use `serve` for a SECURE native-only host
|
||||
# (no plain-HTTP pairing / legacy GCM nonce reuse — security-review #5/#9). The installer's
|
||||
# "Enable GameStream (Moonlight) compatibility" task sets this; a custom value you write here is
|
||||
# never overwritten by a reinstall/upgrade.
|
||||
# The host subcommand the service launches. The native punktfunk/1 host is ALWAYS on; `--gamestream`
|
||||
# adds the GameStream/Moonlight-compat planes, which pair over plain HTTP and reuse legacy GCM
|
||||
# nonces (security-review #5/#9) — so `serve` alone is the SECURE native-only host.
|
||||
#
|
||||
# A Windows install writes this line explicitly from the installer's "Enable GameStream (Moonlight)
|
||||
# compatibility" task, which is OPT-IN (unchecked) — so a default install lands `serve`. Only if
|
||||
# this line is absent altogether does the service fall back to its built-in `serve --gamestream`.
|
||||
# Change it later with `punktfunk-host service install --gamestream=on|off` + a service restart; a
|
||||
# custom value you write here by hand is never overwritten by a reinstall/upgrade.
|
||||
#PUNKTFUNK_HOST_CMD=serve --gamestream
|
||||
|
||||
# Multi-GPU boxes only: force the NVENC/Desktop-Duplication GPU by Description substring. Leave
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "display-disturb"
|
||||
description = "Deterministic display-stack disturbance generator (vdisplay stall-immunity bench)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Devices_Display",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Foundation",
|
||||
] }
|
||||
@@ -0,0 +1,332 @@
|
||||
//! `display-disturb` — deterministic display-stack disturbance generator (Windows).
|
||||
//!
|
||||
//! The vdisplay stall-immunity bench (design: punktfunk-planning
|
||||
//! `design/vdisplay-disturbance-immunity.md`) needs both stall classes reproducible on demand,
|
||||
//! without waiting for a standby TV or a monitor-tool storm:
|
||||
//!
|
||||
//! * `ddc` — Class 2: DDC/CI traffic through the win32k → dxgkrnl → miniport I2C path, exactly
|
||||
//! what Twinkle-Tray/PowerDisplay-class tools emit after every HPD blip (one VCP read ≈ 100 ms,
|
||||
//! a capabilities string up to ~1 s — serialized per physical I2C bus). Requires a physical
|
||||
//! monitor; virtual displays expose no DDC handle.
|
||||
//! * `modeset` — Class 1: a same-mode `ChangeDisplaySettingsExW(CDS_RESET)` re-commit — a
|
||||
//! Level-Two modeset-class DDI entry that idles the whole adapter ("the graphics hardware is
|
||||
//! idle") without changing anything Win32-visible.
|
||||
//!
|
||||
//! Every operation prints `epoch_ms op target duration_ms result` so stalls in a concurrent
|
||||
//! stream's host.log correlate line-for-line. The per-op duration is itself measurement: it is
|
||||
//! the I2C/modeset service time the GPU driver spent, per disturbance.
|
||||
//!
|
||||
//! Usage: `display-disturb ddc [--interval-ms 2000] [--caps] [--vcp 0x10]`
|
||||
//! `display-disturb modeset [--interval-ms 2000]`
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn main() {
|
||||
eprintln!("display-disturb is Windows-only (it exercises the WDDM display stack).");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn main() {
|
||||
win::main()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod win {
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use windows::{
|
||||
core::{BOOL, PCWSTR},
|
||||
Win32::{
|
||||
Devices::Display::{
|
||||
CapabilitiesRequestAndCapabilitiesReply, DestroyPhysicalMonitor,
|
||||
GetCapabilitiesStringLength, GetNumberOfPhysicalMonitorsFromHMONITOR,
|
||||
GetPhysicalMonitorsFromHMONITOR, GetVCPFeatureAndVCPFeatureReply, SetDisplayConfig,
|
||||
PHYSICAL_MONITOR, SDC_APPLY, SDC_TOPOLOGY_EXTEND,
|
||||
},
|
||||
Foundation::{HANDLE, LPARAM, RECT},
|
||||
Graphics::Gdi::{
|
||||
ChangeDisplaySettingsExW, EnumDisplayDevicesW, EnumDisplayMonitors,
|
||||
EnumDisplaySettingsW, GetMonitorInfoW, CDS_RESET, DEVMODEW, DISPLAY_DEVICEW,
|
||||
DISPLAY_DEVICE_ATTACHED_TO_DESKTOP, DISPLAY_DEVICE_MIRRORING_DRIVER,
|
||||
DISP_CHANGE_SUCCESSFUL, ENUM_CURRENT_SETTINGS, HDC, HMONITOR, MONITORINFOEXW,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fn epoch_ms() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// One timed op: print the correlation line the bench greps for.
|
||||
fn report(op: &str, target: &str, took: Duration, ok: bool) {
|
||||
println!(
|
||||
"{} {op} {target} took_ms={} ok={ok}",
|
||||
epoch_ms(),
|
||||
took.as_millis()
|
||||
);
|
||||
}
|
||||
|
||||
struct Args {
|
||||
mode: String,
|
||||
interval: Duration,
|
||||
caps: bool,
|
||||
vcp: u8,
|
||||
}
|
||||
|
||||
fn parse_args() -> Args {
|
||||
let argv: Vec<String> = std::env::args().collect();
|
||||
let mode = argv.get(1).cloned().unwrap_or_default();
|
||||
if !matches!(mode.as_str(), "ddc" | "modeset" | "extend") {
|
||||
eprintln!(
|
||||
"usage: display-disturb <ddc|modeset|extend> [--interval-ms N] [--caps] [--vcp 0xNN]"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
let mut a = Args {
|
||||
mode,
|
||||
interval: Duration::from_millis(2000),
|
||||
caps: false,
|
||||
vcp: 0x10, // brightness — universally implemented, read-only harmless
|
||||
};
|
||||
let mut i = 2;
|
||||
while i < argv.len() {
|
||||
match argv[i].as_str() {
|
||||
"--interval-ms" => {
|
||||
i += 1;
|
||||
a.interval = Duration::from_millis(
|
||||
argv.get(i).and_then(|s| s.parse().ok()).unwrap_or(2000),
|
||||
);
|
||||
}
|
||||
"--caps" => a.caps = true,
|
||||
"--vcp" => {
|
||||
i += 1;
|
||||
let s = argv.get(i).map(String::as_str).unwrap_or("0x10");
|
||||
a.vcp = u8::from_str_radix(s.trim_start_matches("0x"), 16).unwrap_or(0x10);
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown arg: {other}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
pub fn main() {
|
||||
let args = parse_args();
|
||||
eprintln!(
|
||||
"display-disturb: mode={} interval={}ms (Ctrl-C to stop) — correlate `took_ms` lines \
|
||||
against the host log's stall reports",
|
||||
args.mode,
|
||||
args.interval.as_millis()
|
||||
);
|
||||
match args.mode.as_str() {
|
||||
"ddc" => ddc_loop(&args),
|
||||
"extend" => extend_once(),
|
||||
_ => modeset_loop(&args),
|
||||
}
|
||||
}
|
||||
|
||||
/// One `SetDisplayConfig(SDC_TOPOLOGY_EXTEND)` poke — re-activates every attachable display
|
||||
/// from the CCD database, i.e. exactly the "a non-managed display re-activated after the
|
||||
/// verified isolate" event the exclusive-topology watchdog exists to evict. Fired ONCE (not a
|
||||
/// loop): the point is to trigger one reassert round and observe the recovery — one
|
||||
/// `reassert-recover` trace, ONE ring recreate, stream alive.
|
||||
fn extend_once() -> ! {
|
||||
let t = Instant::now();
|
||||
// SAFETY: the no-buffers topology form of SetDisplayConfig; flags request the stored
|
||||
// EXTEND topology be applied — a pure CCD database operation with no pointers involved.
|
||||
let rc = unsafe { SetDisplayConfig(None, None, SDC_TOPOLOGY_EXTEND | SDC_APPLY) };
|
||||
report("topology-extend", "all", t.elapsed(), rc == 0);
|
||||
std::process::exit(if rc == 0 { 0 } else { 1 });
|
||||
}
|
||||
|
||||
// ---- Class 2: the DDC hammer ----
|
||||
|
||||
/// Collect the desktop's HMONITORs (the DDC handles hang off them), labeled by GDI device
|
||||
/// name (`\\.\DISPLAYn`) so a virtual display and a panel are tellable apart in the output.
|
||||
fn monitors() -> Vec<(HMONITOR, String)> {
|
||||
unsafe extern "system" fn cb(mon: HMONITOR, _dc: HDC, _rc: *mut RECT, out: LPARAM) -> BOOL {
|
||||
let mut info = MONITORINFOEXW::default();
|
||||
info.monitorInfo.cbSize = std::mem::size_of::<MONITORINFOEXW>() as u32;
|
||||
// SAFETY: `mon` is the live enumeration handle; `info.cbSize` is stamped for the EX
|
||||
// variant so the device-name field is filled.
|
||||
let name = if unsafe { GetMonitorInfoW(mon, &mut info.monitorInfo) }.as_bool() {
|
||||
let d = info.szDevice;
|
||||
String::from_utf16_lossy(&d[..d.iter().position(|&c| c == 0).unwrap_or(d.len())])
|
||||
} else {
|
||||
"?".into()
|
||||
};
|
||||
// SAFETY: `out.0` is the `&mut Vec<(HMONITOR, String)>` this enumeration call passed
|
||||
// in below, alive for the whole synchronous enumeration.
|
||||
unsafe { &mut *(out.0 as *mut Vec<(HMONITOR, String)>) }.push((mon, name));
|
||||
BOOL(1)
|
||||
}
|
||||
let mut v: Vec<(HMONITOR, String)> = Vec::new();
|
||||
// SAFETY: null dc/clip = enumerate all display monitors; `cb` only touches the Vec whose
|
||||
// address rides in LPARAM for the duration of this synchronous call.
|
||||
let _ =
|
||||
unsafe { EnumDisplayMonitors(None, None, Some(cb), LPARAM(&mut v as *mut _ as isize)) };
|
||||
v
|
||||
}
|
||||
|
||||
/// The physical (DDC-capable) monitors behind one HMONITOR, with their descriptions. The
|
||||
/// handle-ACQUISITION timing is itself reported (`ddc-open`): it is the poller's first contact
|
||||
/// with every monitor — including a virtual one that then yields no handles — and on a
|
||||
/// streaming host in exclusive topology the virtual monitor is the only one there is, so the
|
||||
/// cost of this failing path is exactly what the DDC-fail-fast driver work changes.
|
||||
fn physical_monitors(mon: HMONITOR, label: &str) -> Vec<(HANDLE, String)> {
|
||||
let t = Instant::now();
|
||||
let mut count = 0u32;
|
||||
// SAFETY: valid HMONITOR from enumeration; `count` is a valid out-param.
|
||||
if unsafe { GetNumberOfPhysicalMonitorsFromHMONITOR(mon, &mut count) }.is_err()
|
||||
|| count == 0
|
||||
{
|
||||
report("ddc-open", label, t.elapsed(), false);
|
||||
return Vec::new();
|
||||
}
|
||||
let mut phys = vec![PHYSICAL_MONITOR::default(); count as usize];
|
||||
// SAFETY: `phys` holds exactly `count` entries as the API requires.
|
||||
let got = unsafe { GetPhysicalMonitorsFromHMONITOR(mon, &mut phys) }.is_ok();
|
||||
report("ddc-open", label, t.elapsed(), got);
|
||||
if !got {
|
||||
return Vec::new();
|
||||
}
|
||||
phys.iter()
|
||||
.map(|p| {
|
||||
// Copy the field out: PHYSICAL_MONITOR is packed(1), so referencing the array
|
||||
// in place would be an unaligned reference (E0793).
|
||||
let name = p.szPhysicalMonitorDescription;
|
||||
let desc = String::from_utf16_lossy(
|
||||
&name[..name.iter().position(|&c| c == 0).unwrap_or(0)],
|
||||
);
|
||||
(p.hPhysicalMonitor, desc.trim().to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ddc_loop(args: &Args) -> ! {
|
||||
let mut warned_none = false;
|
||||
loop {
|
||||
let mut any = false;
|
||||
for (mon, dev) in monitors() {
|
||||
for (h, desc) in physical_monitors(mon, &dev) {
|
||||
any = true;
|
||||
let label = if desc.is_empty() {
|
||||
"monitor".into()
|
||||
} else {
|
||||
desc.clone()
|
||||
};
|
||||
if args.caps {
|
||||
// The heavy transaction: length + full capabilities string (the
|
||||
// PowerDisplay-discovery load, ~100 ms-1 s of serialized I2C).
|
||||
let t = Instant::now();
|
||||
let mut len = 0u32;
|
||||
// SAFETY: `h` is a live physical-monitor handle; `len` a valid out-param.
|
||||
let mut ok =
|
||||
unsafe { GetCapabilitiesStringLength(h, &mut len) } == 1 && len > 0;
|
||||
if ok {
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
// SAFETY: `buf` is exactly the reported capabilities length.
|
||||
ok = unsafe { CapabilitiesRequestAndCapabilitiesReply(h, &mut buf) }
|
||||
== 1;
|
||||
}
|
||||
report("ddc-caps", &label, t.elapsed(), ok);
|
||||
} else {
|
||||
let t = Instant::now();
|
||||
let (mut cur, mut max) = (0u32, 0u32);
|
||||
// SAFETY: `h` is a live physical-monitor handle; out-params are valid; a
|
||||
// read of a standard VCP code mutates nothing monitor-side.
|
||||
let ok = unsafe {
|
||||
GetVCPFeatureAndVCPFeatureReply(
|
||||
h,
|
||||
args.vcp,
|
||||
None,
|
||||
&mut cur,
|
||||
Some(&mut max),
|
||||
)
|
||||
} == 1;
|
||||
report("ddc-vcp", &label, t.elapsed(), ok);
|
||||
}
|
||||
// SAFETY: closing the handle GetPhysicalMonitorsFromHMONITOR returned.
|
||||
let _ = unsafe { DestroyPhysicalMonitor(h) };
|
||||
}
|
||||
}
|
||||
if !any && !warned_none {
|
||||
warned_none = true;
|
||||
eprintln!(
|
||||
"no DDC-capable (physical) monitor yielded handles — continuing anyway: the \
|
||||
ddc-open timing against handle-less monitors (virtual displays included) IS \
|
||||
the measurement on a streaming host"
|
||||
);
|
||||
}
|
||||
std::thread::sleep(args.interval);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Class 1: the no-op modeset tick ----
|
||||
|
||||
/// The first attached, non-mirroring display device (device name, e.g. `\\.\DISPLAY1`).
|
||||
fn first_active_display() -> Option<[u16; 32]> {
|
||||
for i in 0u32.. {
|
||||
let mut dd = DISPLAY_DEVICEW {
|
||||
cb: std::mem::size_of::<DISPLAY_DEVICEW>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: null device name = enumerate adapters by index; `dd.cb` is stamped.
|
||||
if unsafe { !EnumDisplayDevicesW(PCWSTR::null(), i, &mut dd, 0).as_bool() } {
|
||||
return None;
|
||||
}
|
||||
if (dd.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP).0 != 0
|
||||
&& (dd.StateFlags & DISPLAY_DEVICE_MIRRORING_DRIVER).0 == 0
|
||||
{
|
||||
return Some(dd.DeviceName);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn modeset_loop(args: &Args) -> ! {
|
||||
let Some(name) = first_active_display() else {
|
||||
eprintln!("no active display device found");
|
||||
std::process::exit(1);
|
||||
};
|
||||
let label = String::from_utf16_lossy(
|
||||
&name[..name.iter().position(|&c| c == 0).unwrap_or(name.len())],
|
||||
);
|
||||
loop {
|
||||
let mut dm = DEVMODEW {
|
||||
dmSize: std::mem::size_of::<DEVMODEW>() as u16,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: `name` is the NUL-terminated device string from enumeration; `dm.dmSize`
|
||||
// is stamped; ENUM_CURRENT_SETTINGS fills the live mode.
|
||||
let got = unsafe {
|
||||
EnumDisplaySettingsW(PCWSTR(name.as_ptr()), ENUM_CURRENT_SETTINGS, &mut dm)
|
||||
}
|
||||
.as_bool();
|
||||
if !got {
|
||||
eprintln!("EnumDisplaySettingsW failed for {label}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let t = Instant::now();
|
||||
// SAFETY: re-applying the CURRENT mode with CDS_RESET — the same-mode re-commit that
|
||||
// forces the modeset path without changing anything user-visible. Null hwnd + null
|
||||
// lparam per the API contract for this flag combination.
|
||||
let rc = unsafe {
|
||||
ChangeDisplaySettingsExW(PCWSTR(name.as_ptr()), Some(&dm), None, CDS_RESET, None)
|
||||
};
|
||||
report(
|
||||
"modeset-reset",
|
||||
&label,
|
||||
t.elapsed(),
|
||||
rc == DISP_CHANGE_SUCCESSFUL,
|
||||
);
|
||||
std::thread::sleep(args.interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-9
@@ -45,6 +45,7 @@
|
||||
},
|
||||
},
|
||||
"overrides": {
|
||||
"brace-expansion": "^5.0.8",
|
||||
"dompurify": "^3.4.12",
|
||||
"fast-uri": "^3.1.4",
|
||||
"immutable": "^4.3.9",
|
||||
@@ -1116,7 +1117,7 @@
|
||||
|
||||
"body-scroll-lock": ["body-scroll-lock@4.0.0-beta.0", "", {}, "sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
|
||||
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
@@ -2544,8 +2545,6 @@
|
||||
|
||||
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||
|
||||
"readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="],
|
||||
|
||||
"rollup-plugin-visualizer/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
|
||||
|
||||
"sass/chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
@@ -2556,14 +2555,8 @@
|
||||
|
||||
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="],
|
||||
|
||||
"archiver-utils/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"sass/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"archiver-utils/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
}
|
||||
}
|
||||
|
||||
+427
-415
@@ -1,417 +1,429 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"app_name": "Punktfunk",
|
||||
"app_tagline": "Verwaltungskonsole",
|
||||
"display_settings_saved": "Display-Konfiguration gespeichert",
|
||||
"nav_dashboard": "Übersicht",
|
||||
"nav_host": "Host",
|
||||
"nav_displays": "Virtuelle Anzeigen",
|
||||
"nav_clients": "Gekoppelte Geräte",
|
||||
"nav_pairing": "Kopplung",
|
||||
"nav_library": "Bibliothek",
|
||||
"nav_plugins": "Plugins",
|
||||
"plugin_offline_title": "Dieses Plugin läuft nicht",
|
||||
"plugin_offline_hint": "Starte den Scripting-Runner und versuche es erneut.",
|
||||
"plugin_retry": "Erneut versuchen",
|
||||
"plugin_open_new_tab": "In neuem Tab öffnen",
|
||||
"nav_settings": "Einstellungen",
|
||||
"nav_more": "Mehr",
|
||||
"status_title": "Live-Status",
|
||||
"status_video": "Video",
|
||||
"status_audio": "Audio",
|
||||
"status_streaming": "Aktiv",
|
||||
"status_idle": "Inaktiv",
|
||||
"status_session": "Sitzung",
|
||||
"status_sessions_active": "{count} aktiv",
|
||||
"status_no_session": "Keine aktive Sitzung",
|
||||
"status_paired_count": "Gekoppelte Geräte",
|
||||
"status_pin_pending": "Kopplungs-PIN ausstehend",
|
||||
"stream_codec": "Codec",
|
||||
"stream_resolution": "Auflösung",
|
||||
"stream_fps": "Bildrate",
|
||||
"stream_bitrate": "Bitrate",
|
||||
"action_stop_session": "Sitzung beenden",
|
||||
"action_request_idr": "Keyframe anfordern",
|
||||
"action_unpair": "Entkoppeln",
|
||||
"host_identity": "Identität",
|
||||
"host_hostname": "Hostname",
|
||||
"host_local_ip": "Lokale IP",
|
||||
"host_version": "Version",
|
||||
"host_abi": "ABI-Version",
|
||||
"host_codecs": "Codecs",
|
||||
"host_ports": "Ports",
|
||||
"host_uniqueid": "Eindeutige ID",
|
||||
"host_compositors": "Compositoren",
|
||||
"host_compositors_help": "Backends, auf denen der Host eine virtuelle Ausgabe erzeugen kann. Übergib eine ID an das --compositor-Flag eines Clients; der Host nutzt sie, falls verfügbar, sonst per Auto-Erkennung.",
|
||||
"compositor_available": "Verfügbar",
|
||||
"compositor_unavailable": "Nicht verfügbar",
|
||||
"compositor_default": "Standard",
|
||||
"host_gpus": "GPUs",
|
||||
"host_gpus_help": "Die GPU, auf der der Host aufnimmt und encodiert. Automatisch wählt die beste GPU; eine bevorzugte GPU bindet Aufnahme + Encoding an sie. Eine Änderung gilt ab der nächsten Sitzung.",
|
||||
"gpu_automatic": "Automatisch",
|
||||
"gpu_prefer": "Bevorzugen",
|
||||
"gpu_preferred": "Bevorzugt",
|
||||
"gpu_in_use": "In Benutzung · {backend}",
|
||||
"gpu_next_session": "Nächste Sitzung",
|
||||
"gpu_none": "Keine GPUs erkannt.",
|
||||
"gpu_missing_warning": "Die bevorzugte GPU „{name}“ ist nicht vorhanden — stattdessen wird automatisch gewählt.",
|
||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
|
||||
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} bindet das Encoder-Backend.",
|
||||
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} bindet einen {vendor}-Encoder, aber die GPU der nächsten Sitzung ist „{name}“ — die veraltete Bindung sollte aus host.env entfernt werden.",
|
||||
"host_displays": "Virtuelle Displays",
|
||||
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
|
||||
"display_config_title": "Konfiguration",
|
||||
"display_preset": "Voreinstellung",
|
||||
"display_preset_custom": "Benutzerdefiniert",
|
||||
"display_preset_default": "Standard",
|
||||
"display_preset_gaming_rig": "Headless-Box",
|
||||
"display_preset_shared_desktop": "Geteilter Desktop",
|
||||
"display_preset_hotdesk": "Hot-Desk",
|
||||
"display_preset_workstation": "Workstation",
|
||||
"display_keep_alive": "Nach Trennung aktiv halten",
|
||||
"display_keep_alive_off": "Aus",
|
||||
"display_keep_alive_keep": "Behalten für",
|
||||
"display_keep_alive_forever": "Dauerhaft",
|
||||
"display_keep_alive_seconds": "Sekunden",
|
||||
"display_topology": "Topologie",
|
||||
"display_topology_auto": "Automatisch",
|
||||
"display_topology_extend": "Erweitern",
|
||||
"display_topology_primary": "Primär",
|
||||
"display_topology_exclusive": "Exklusiv",
|
||||
"display_max": "Max. Displays",
|
||||
"display_save": "Speichern",
|
||||
"display_effective": "Aktiv",
|
||||
"display_pending_note": "Änderungen greifen ab der nächsten Verbindung — eine laufende Sitzung behält die Anzeige, mit der sie gestartet ist.",
|
||||
"display_live": "Aktive Displays",
|
||||
"display_none_live": "Derzeit keine virtuellen Displays.",
|
||||
"display_state_active": "Aktiv",
|
||||
"display_state_lingering": "Wird gehalten",
|
||||
"display_state_pinned": "Angeheftet",
|
||||
"display_release_btn": "Freigeben",
|
||||
"display_release_all": "Alle gehaltenen freigeben",
|
||||
"display_expires_in": "Abbau in {sec}s",
|
||||
"display_sessions": "{count} streamend",
|
||||
"display_arrange": "Anzeigen anordnen",
|
||||
"display_arrange_help": "Legen Sie fest, wo jede gestreamte Anzeige auf dem Desktop sitzt (in Pixeln). Beim Speichern wird auf ein manuelles Layout umgeschaltet; es greift ab der nächsten Verbindung.",
|
||||
"display_arrange_save": "Anordnung speichern",
|
||||
"display_custom_desc": "Jede Option selbst festlegen.",
|
||||
"display_preset_current": "Aktiv",
|
||||
"display_preset_soon": "in Kürze",
|
||||
"display_keep_alive_help": "„Aus“ baut die Anzeige sofort beim Trennen ab. Halte sie (und bei gamescope ihr Spiel) am Leben, damit ein schnelles Wiederverbinden sofort fortsetzt, statt neu aufzubauen.",
|
||||
"display_topology_help": "Wie sich die gestreamte Anzeige in den Desktop des Hosts einfügt. Erweitern: ein zusätzlicher Bildschirm neben deinen Monitoren. Primär: die gestreamte Anzeige wird der primäre Ausgang, deine Monitore bleiben. Exklusiv: die gestreamte Anzeige ist der einzige Ausgang — physische Monitore werden beim Streamen deaktiviert und danach wiederhergestellt. Auf einem Host ohne Monitor legt es nur fest, ob die gestreamte Anzeige primär ist.",
|
||||
"display_conflict": "Wenn ein weiterer Client verbindet",
|
||||
"display_conflict_help": "Was passiert, wenn ein zweiter Client verbindet, während bereits gestreamt wird, und eine andere Auflösung anfragt.",
|
||||
"display_conflict_separate": "Eigene Anzeige",
|
||||
"display_conflict_steal": "Übernehmen",
|
||||
"display_conflict_join": "Ansicht teilen",
|
||||
"display_conflict_reject": "Besetzt — ablehnen",
|
||||
"display_identity": "Client-Identität",
|
||||
"display_identity_help": "Ob die gestreamte Anzeige eine stabile Client-Identität trägt, sodass der Desktop des Hosts die Monitor-Einstellungen dieses Clients (Skalierung, Auflösung) merkt und beim erneuten Verbinden wieder anwendet. Geteilt: eine Identität für alle. Pro Client: jedes Gerät eigene. Pro Client + Auflösung: separate Einstellungen je Gerät und Auflösung.",
|
||||
"display_game_session": "Dedizierte Spiel-Sitzungen",
|
||||
"display_game_session_help": "Wie eine Sitzung bedient wird, die ein Spiel aus der Bibliothek startet. „Dediziert“ gibt dem Start immer ein eigenes headless-gamescope in genau deiner Auflösung — das Spiel startet direkt, ohne Steam Big Picture, ohne Game-Mode. „Auto“ nutzt die aktuelle Sitzung der Box. gamescope muss installiert sein; sonst fällt Dediziert auf Auto zurück.",
|
||||
"display_game_session_auto": "Auto",
|
||||
"display_game_session_dedicated": "Dediziert",
|
||||
"display_experimental": "Experimentell",
|
||||
"display_ddc": "Monitore beim Streamen ausschalten (DDC/CI)",
|
||||
"display_ddc_help": "Nur Windows, wirkt bei Topologie Exklusiv. Bevor die physischen Monitore deaktiviert werden, weist der Host sie zusätzlich über den DDC/CI-Steuerkanal an, ihr Panel auszuschalten — und weckt sie am Ende des Streams wieder. Das kann ein periodisches Ruckeln beheben, das manche Setups zeigen, wenn die gestreamte Anzeige die einzige aktive ist (der dunkle Monitor sucht sonst weiter seine Eingänge ab; ein schlafen gelegtes Panel nicht). Monitore ohne DDC/CI werden übersprungen — falls ein Monitor danach nicht aufwacht, einmal seinen Power-Knopf drücken und die Option ausschalten.",
|
||||
"display_ddc_disabled": "Aus",
|
||||
"display_ddc_enabled": "Ein",
|
||||
"display_ddc_badge": "Monitore aus via DDC/CI",
|
||||
"display_pnp": "Monitor-Geräte beim Streamen deaktivieren (PnP)",
|
||||
"display_pnp_help": "Nur Windows, wirkt bei Topologie Exklusiv. Zusätzlich zum Entfernen der physischen Monitore vom Desktop deaktiviert der Host deren Windows-Geräteeinträge für die Dauer des Streams und aktiviert sie danach wieder. Ein Monitor oder Fernseher im Standby, der seine Verbindung immer wieder aufweckt (automatische Eingangssuche, Instant-On), kann den Stream so nicht mehr stören — Windows ignoriert seine Aufwach-Ereignisse vollständig, solange das Gerät deaktiviert ist. Stürzt der Host mitten im Stream ab, werden die Monitore beim nächsten Start wieder aktiviert; bis dahin lassen sie sich manuell im Geräte-Manager aktivieren.",
|
||||
"display_pnp_disabled": "Aus",
|
||||
"display_pnp_enabled": "Ein",
|
||||
"display_pnp_badge": "Monitor-Geräte deaktiviert (PnP)",
|
||||
"display_identity_shared": "Geteilt",
|
||||
"display_identity_per_client": "Pro Client",
|
||||
"display_identity_per_client_mode": "Pro Client + Auflösung",
|
||||
"display_layout_mode": "Multi-Monitor-Anordnung",
|
||||
"display_layout_help": "Automatisch ordnet die Anzeigen nebeneinander an (links nach rechts). Manuell: Du platzierst jede selbst — ein X/Y-Editor pro Anzeige erscheint im Abschnitt „Aktive Displays“ unten, sobald zwei oder mehr streamen.",
|
||||
"display_layout_auto_row": "Automatisch (nebeneinander)",
|
||||
"display_layout_manual": "Manuell",
|
||||
"display_preset_custom_label": "Eigene Voreinstellungen",
|
||||
"display_preset_save_as": "Als Voreinstellung speichern…",
|
||||
"display_preset_name": "Name der Voreinstellung",
|
||||
"display_preset_edit": "Umbenennen",
|
||||
"display_preset_update": "Auf aktuelle Einstellungen aktualisieren",
|
||||
"display_preset_delete": "Löschen",
|
||||
"display_preset_delete_confirm": "Diese eigene Voreinstellung löschen?",
|
||||
"clients_title": "Gekoppelte Geräte",
|
||||
"clients_empty": "Noch keine gekoppelten Geräte.",
|
||||
"clients_name": "Name",
|
||||
"clients_fingerprint": "Fingerabdruck",
|
||||
"clients_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
|
||||
"pairing_title": "Kopplung",
|
||||
"pairing_idle": "Keine Kopplung aktiv. Starte die Kopplung in einem Moonlight-Client und gib hier die PIN ein.",
|
||||
"pairing_waiting": "Ein Gerät wartet auf Kopplung. Gib die angezeigte PIN ein:",
|
||||
"pairing_pin_label": "PIN",
|
||||
"pairing_submit": "PIN bestätigen",
|
||||
"pairing_pin_sent": "PIN gesendet. Wenn sie stimmt, schließt der Client die Kopplung ab und erscheint unten unter „Gekoppelte Geräte“.",
|
||||
"pairing_failed": "PIN konnte nicht übermittelt werden — prüfe, ob sie stimmt und der Client noch auf die Kopplung wartet.",
|
||||
"pairing_native_title": "Gerät koppeln",
|
||||
"pairing_native_desc": "Zeige hier eine Einmal-PIN an und gib sie in deiner Punktfunk-App ein, um dieses Gerät zu koppeln.",
|
||||
"pairing_native_disabled": "Der native Host läuft nicht. Starte ihn mit `serve --native`, um Punktfunk-Geräte zu koppeln.",
|
||||
"pairing_native_arm": "Gerät koppeln",
|
||||
"pairing_native_enter": "Gib diese PIN auf deinem Gerät ein:",
|
||||
"pairing_native_expires": "Läuft ab in",
|
||||
"pairing_native_cancel": "Abbrechen",
|
||||
"pairing_native_devices": "Gekoppelte Geräte",
|
||||
"pairing_native_empty": "Noch keine Geräte gekoppelt.",
|
||||
"pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
|
||||
"pairing_protocol": "Protokoll",
|
||||
"pairing_protocol_native": "punktfunk/1",
|
||||
"pairing_protocol_moonlight": "Moonlight",
|
||||
"pairing_pending_title": "Warten auf Freigabe",
|
||||
"pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.",
|
||||
"pairing_pending_approve": "Freigeben",
|
||||
"pairing_pending_deny": "Ablehnen",
|
||||
"pairing_pending_name_prompt": "Gerät benennen:",
|
||||
"pairing_pending_age_just_now": "gerade eben",
|
||||
"pairing_pending_age_secs": "vor {s}s",
|
||||
"pairing_pending_age_mins": "vor {min} min",
|
||||
"pairing_moonlight_title": "Moonlight-Kopplung (GameStream)",
|
||||
"library_title": "Bibliothek",
|
||||
"library_empty": "Noch keine Spiele gefunden.",
|
||||
"library_sources_title": "Spielequellen",
|
||||
"library_sources_help": "Launcher, die dieser Host nach installierten Spielen durchsucht. Eine Quelle abschalten blendet ihre Spiele auf allen Geräten aus — nichts wird gelöscht, und beim Wiedereinschalten sind sie sofort zurück.",
|
||||
"library_sources_failed": "Die Spielequelle konnte nicht aktualisiert werden.",
|
||||
"library_store_steam": "Steam",
|
||||
"library_store_custom": "Eigene",
|
||||
"library_add_title": "Eigenes Spiel hinzufügen",
|
||||
"library_edit_title": "Eigenes Spiel bearbeiten",
|
||||
"library_add_button": "Eigenes Spiel hinzufügen",
|
||||
"library_field_title": "Titel",
|
||||
"library_field_portrait": "Portrait-Bild-URL",
|
||||
"library_field_hero": "Hero-Bild-URL",
|
||||
"library_field_header": "Header-Bild-URL",
|
||||
"library_field_logo": "Logo-Bild-URL",
|
||||
"library_field_command": "Startbefehl",
|
||||
"library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.",
|
||||
"library_field_platform": "Plattform",
|
||||
"library_field_platform_help": "Das System, auf dem dieser Titel läuft, z. B. PS2, Xbox 360, SNES, PC.",
|
||||
"library_field_description": "Beschreibung",
|
||||
"library_field_developer": "Entwickler",
|
||||
"library_field_publisher": "Publisher",
|
||||
"library_field_release_year": "Erscheinungsjahr",
|
||||
"library_field_genres": "Genres",
|
||||
"library_field_genres_help": "Kommagetrennt, z. B. RPG, Plattformer.",
|
||||
"library_field_tags": "Tags",
|
||||
"library_field_tags_help": "Kommagetrennte Labels zum Organisieren, z. B. Koop, Kinder.",
|
||||
"library_field_region": "Region",
|
||||
"library_field_region_help": "z. B. NTSC-U, PAL, NTSC-J.",
|
||||
"library_field_players": "Spieler",
|
||||
"library_details_legend": "Details (optional)",
|
||||
"library_save": "Speichern",
|
||||
"library_create": "Hinzufügen",
|
||||
"library_cancel": "Abbrechen",
|
||||
"library_edit": "Bearbeiten",
|
||||
"library_delete": "Löschen",
|
||||
"library_delete_confirm": "Dieses eigene Spiel löschen? Das kann nicht rückgängig gemacht werden.",
|
||||
"settings_title": "Einstellungen",
|
||||
"settings_token_label": "API-Token",
|
||||
"settings_token_help": "Bearer-Token für die Verwaltungs-API. Bei einem Loopback-Host ohne Token leer lassen.",
|
||||
"settings_language": "Sprache",
|
||||
"settings_save": "Speichern",
|
||||
"settings_saved": "Gespeichert.",
|
||||
"common_loading": "Wird geladen…",
|
||||
"common_error": "Etwas ist schiefgelaufen.",
|
||||
"common_retry": "Erneut versuchen",
|
||||
"common_yes": "Ja",
|
||||
"common_cancel": "Abbrechen",
|
||||
"common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…",
|
||||
"login_title": "Anmelden",
|
||||
"login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren. Du weißt nicht weiter?",
|
||||
"login_docs_link": "Besuche die Dokumentation",
|
||||
"login_password": "Passwort",
|
||||
"login_submit": "Anmelden",
|
||||
"login_error": "Falsches Passwort.",
|
||||
"login_signing_in": "Anmeldung läuft…",
|
||||
"action_logout": "Abmelden",
|
||||
"settings_logout_failed": "Abmelden fehlgeschlagen — du bist weiterhin angemeldet. Bitte versuche es erneut.",
|
||||
"nav_stats": "Leistung",
|
||||
"nav_logs": "Logs",
|
||||
"logs_title": "Logs",
|
||||
"logs_subtitle": "Der aktuelle Log-Stream des Hosts — live verfolgen, nach Level filtern, durchsuchen.",
|
||||
"logs_follow": "Folgen",
|
||||
"logs_pause": "Pause",
|
||||
"logs_clear": "Leeren",
|
||||
"logs_search": "Logs durchsuchen…",
|
||||
"logs_empty": "Keine passenden Logeinträge — Filter anpassen oder auf Host-Aktivität warten.",
|
||||
"logs_dropped": "Einige Einträge wurden verdrängt, bevor sie abgeholt werden konnten",
|
||||
"stats_title": "Leistung",
|
||||
"stats_subtitle": "Zeichne die Pipeline-Zeiten einer Sitzung auf und betrachte sie als Diagramme.",
|
||||
"stats_capture_title": "Aufzeichnung",
|
||||
"stats_capture_desc": "Aufzeichnung scharfschalten, eine Sitzung fahren, dann stoppen, um eine Aufnahme zu speichern. Die Abtastung erfolgt an der Aggregationsgrenze des Hosts — kein Overhead pro Frame.",
|
||||
"stats_recording": "Zeichnet auf",
|
||||
"stats_idle": "Inaktiv",
|
||||
"stats_start": "Aufzeichnung starten",
|
||||
"stats_stop": "Stoppen & speichern",
|
||||
"stats_elapsed": "Vergangen",
|
||||
"stats_samples": "Proben",
|
||||
"stats_kind": "Pfad",
|
||||
"stats_kind_native": "Nativ",
|
||||
"stats_kind_gamestream": "GameStream",
|
||||
"stats_live_title": "Live",
|
||||
"stats_live_waiting": "Scharf — warte auf die ersten Proben. Starte eine Sitzung, um aufzuzeichnen.",
|
||||
"stats_latency_title": "Latenz nach Stufe",
|
||||
"stats_latency_axis": "µs",
|
||||
"stats_latency_desc": "Pipeline-Zeit pro Stufe, gestapelt — die Ansicht „wohin geht die Zeit“.",
|
||||
"stats_throughput_title": "Durchsatz",
|
||||
"stats_health_title": "Zustand",
|
||||
"stats_fps_new": "Neue fps",
|
||||
"stats_fps_repeat": "Wiederholte fps",
|
||||
"stats_mbps": "Mb/s",
|
||||
"stats_bitrate_target": "Ziel-Mb/s",
|
||||
"stats_health_gamestream_note": "Paketverlust, Sende-Drops und FEC-Wiederherstellung sind empfängerseitige Werte, die der GameStream-Pfad nicht erfasst — hier werden nur Frame-Drops angezeigt.",
|
||||
"stats_p99": "p99",
|
||||
"stats_p50": "p50",
|
||||
"stats_frames_dropped": "Verworfene Frames",
|
||||
"stats_packets_dropped": "Verworfene Pakete",
|
||||
"stats_send_dropped": "Sende-Verluste",
|
||||
"stats_fec_recovered": "FEC wiederhergestellt",
|
||||
"stats_recordings_title": "Aufnahmen",
|
||||
"stats_recordings_empty": "Noch keine Aufnahmen. Starte eine Aufzeichnung, um eine anzulegen.",
|
||||
"stats_col_time": "Zeit",
|
||||
"stats_col_kind": "Pfad",
|
||||
"stats_col_resolution": "Auflösung",
|
||||
"stats_col_codec": "Codec",
|
||||
"stats_col_duration": "Dauer",
|
||||
"stats_col_samples": "Proben",
|
||||
"stats_view": "Ansehen",
|
||||
"stats_download": "Herunterladen",
|
||||
"stats_delete": "Löschen",
|
||||
"stats_delete_confirm": "Diese Aufnahme löschen? Das kann nicht rückgängig gemacht werden.",
|
||||
"stats_detail_title": "Aufnahme-Details",
|
||||
"stats_close": "Schließen",
|
||||
"stats_no_samples": "Diese Aufnahme enthält keine Proben.",
|
||||
"store_title": "Plugins",
|
||||
"store_subtitle": "Durchstöbere den Plugin-Katalog, installiere und entferne Plugins und lege fest, welchen Katalogen dieser Host vertraut.",
|
||||
"store_tab_browse": "Entdecken",
|
||||
"store_tab_installed": "Installiert",
|
||||
"store_tab_sources": "Quellen",
|
||||
"store_tier_verified": "Geprüft",
|
||||
"store_tier_verified_hint": "Aus dem eingebauten unom-Katalog — unom hat genau dieses Paket geprüft.",
|
||||
"store_tier_external": "Externe Quelle",
|
||||
"store_tier_external_hint": "Festgepinnt und auf Integrität geprüft, aber von jemand anderem als unom kuratiert. Niemand bei unom hat diesen Code geprüft.",
|
||||
"store_tier_unverified": "Ungeprüft",
|
||||
"store_tier_unverified_hint": "Aus einer rohen Paketangabe installiert. Kein Katalog, keine Prüfung — diesen Code hat nie jemand kontrolliert.",
|
||||
"store_tier_cli": "Über die CLI installiert",
|
||||
"store_tier_cli_hint": "Von der Kommandozeile installiert — der Host weiß daher nicht, woher es stammt.",
|
||||
"store_from_source": "von",
|
||||
"store_search_placeholder": "Plugins durchsuchen…",
|
||||
"store_filter_all": "Alle Quellen",
|
||||
"store_empty": "Noch keine Plugins im Katalog.",
|
||||
"store_no_match": "Kein Plugin passt zur Suche.",
|
||||
"store_by_author": "von {author}",
|
||||
"store_homepage": "Website",
|
||||
"store_install": "Installieren",
|
||||
"store_installed_label": "Installiert",
|
||||
"store_update_to": "Auf {version} aktualisieren",
|
||||
"store_incompatible": "Nicht kompatibel mit diesem Host.",
|
||||
"store_blocked": "Vom Host blockiert: {reason}",
|
||||
"store_spec_open": "Aus Paketangabe installieren…",
|
||||
"store_runner_title": "Plugin-Runner",
|
||||
"store_runner_help": "Der Dienst, in dem jedes Plugin läuft. Ihn abzuschalten stoppt alle Plugins auf einmal, ohne etwas zu deinstallieren.",
|
||||
"store_runner_not_installed": "Der Plugin-Runner ist auf diesem Host nicht installiert — Plugins können daher nicht starten.",
|
||||
"store_runner_banner": "Der Plugin-Runner ist ausgeschaltet — Plugins starten erst, wenn du ihn aktivierst.",
|
||||
"store_runner_enable": "Runner aktivieren",
|
||||
"store_runner_disable": "Runner deaktivieren",
|
||||
"store_runner_state_running": "Läuft",
|
||||
"store_runner_state_stopped": "Gestoppt",
|
||||
"store_runner_state_disabled": "Deaktiviert",
|
||||
"store_runner_state_missing": "Nicht installiert",
|
||||
"store_runner_unit": "Dienst",
|
||||
"store_runner_principal": "Läuft als",
|
||||
"store_runner_failed": "Der Plugin-Runner konnte nicht umgeschaltet werden.",
|
||||
"store_installed_title": "Installierte Plugins",
|
||||
"store_installed_empty": "Noch keine Plugins installiert.",
|
||||
"store_running": "Läuft",
|
||||
"store_stopped": "Läuft nicht",
|
||||
"store_uninstall": "Deinstallieren",
|
||||
"store_uninstall_confirm": "{title} deinstallieren? Du kannst es jederzeit wieder aus dem Katalog installieren.",
|
||||
"store_uninstall_failed": "Die Deinstallation konnte nicht gestartet werden.",
|
||||
"store_update_no_entry": "Dieses Plugin steckt derzeit in keinem Katalog — aktualisiere die Quellen und versuche es erneut.",
|
||||
"store_sources_title": "Katalogquellen",
|
||||
"store_sources_help": "Wo dieser Host nach Plugins sucht. Der eingebaute unom-Katalog ist immer dabei; jede weitere Quelle hast du selbst hinzugefügt und stehst selbst dafür ein.",
|
||||
"store_refresh_all": "Alle aktualisieren",
|
||||
"store_refresh_failed": "Die Kataloge konnten nicht aktualisiert werden.",
|
||||
"store_source_builtin": "Eingebaut",
|
||||
"store_source_signed": "Signiert",
|
||||
"store_source_unsigned": "Unsigniert",
|
||||
"store_source_stale": "Veraltet",
|
||||
"store_source_entries": "{count} Plugins",
|
||||
"store_source_fetched": "zuletzt geladen {when}",
|
||||
"store_source_never": "nie",
|
||||
"store_source_remove": "Quelle entfernen",
|
||||
"store_source_remove_confirm": "Die Quelle „{name}“ entfernen? Bereits daraus installierte Plugins bleiben installiert.",
|
||||
"store_source_remove_failed": "Die Quelle konnte nicht entfernt werden.",
|
||||
"store_source_builtin_locked": "Der eingebaute unom-Katalog kann nicht entfernt werden.",
|
||||
"store_add_source_title": "Katalogquelle hinzufügen",
|
||||
"store_field_source_name": "Name",
|
||||
"store_field_source_url": "Index-URL",
|
||||
"store_field_source_key": "Öffentlicher Schlüssel (optional)",
|
||||
"store_field_source_key_help": "Ein ed25519:…-Schlüssel. Ist er gesetzt, akzeptiert der Host von dieser Quelle nur einen signierten Index.",
|
||||
"store_add_source": "Quelle hinzufügen",
|
||||
"store_add_source_failed": "Die Quelle konnte nicht gespeichert werden — prüfe Name und URL.",
|
||||
"store_source_trust_title": "Dieser Quelle vertrauen?",
|
||||
"store_source_trust_body": "Alles, was du aus „{name}“ installierst, ist Code, den unom nicht geprüft hat. Er läuft auf diesem Host mit den Rechten des Plugin-Runners. Füge nur einen Katalog hinzu, dessen Betreiber du vertraust.",
|
||||
"store_source_trust_unsigned": "Ohne öffentlichen Schlüssel kann der Host nicht erkennen, ob dieser Index unterwegs manipuliert wurde.",
|
||||
"store_source_trust_confirm": "Verstanden — Quelle hinzufügen",
|
||||
"store_install_title": "{title} installieren?",
|
||||
"store_install_verified_body": "Version {version} aus dem eingebauten unom-Katalog. unom hat genau dieses Paket geprüft.",
|
||||
"store_install_confirm": "Installieren",
|
||||
"store_install_external_title": "{title} aus einer externen Quelle installieren?",
|
||||
"store_install_external_body": "Version {version} stammt aus „{source}“ — einem Katalog, den du selbst hinzugefügt hast.",
|
||||
"store_install_external_note": "unom hat diesen Code nicht geprüft. Das Paket ist festgepinnt und auf Integrität geprüft, läuft auf diesem Host aber mit den Rechten des Plugin-Runners.",
|
||||
"store_install_external_confirm": "Trotzdem installieren",
|
||||
"store_install_failed": "Die Installation konnte nicht gestartet werden.",
|
||||
"store_busy": "Der Host installiert oder entfernt gerade schon ein Plugin. Versuche es, sobald das durch ist.",
|
||||
"store_spec_title": "Aus einer Paketangabe installieren",
|
||||
"store_spec_lead": "Das installiert Code direkt aus einer Paket-Registry. Er steht in keinem Katalog, niemand hat ihn geprüft, und er läuft auf diesem Host mit den Rechten des Plugin-Runners — mit demselben Zugriff auf deine Dateien und deine Sitzung.",
|
||||
"store_spec_permanent": "Ein so installiertes Plugin bleibt als „Ungeprüft“ markiert, solange es installiert ist.",
|
||||
"store_spec_field": "Paketangabe",
|
||||
"store_spec_field_help": "Zum Beispiel @scope/plugin-name@1.2.3.",
|
||||
"store_spec_confirm_field": "Gib die Paketangabe zur Bestätigung erneut ein",
|
||||
"store_spec_checkbox": "Mir ist klar, dass hier ungeprüfter Code mit Betreiberrechten ausgeführt wird.",
|
||||
"store_spec_confirm": "Ungeprüft installieren",
|
||||
"store_job_install": "{target} wird installiert",
|
||||
"store_job_uninstall": "{target} wird entfernt",
|
||||
"store_job_done_install": "Installiert.",
|
||||
"store_job_done_uninstall": "Entfernt.",
|
||||
"store_job_failed": "Der Vorgang ist fehlgeschlagen.",
|
||||
"store_job_restarting": "Der Plugin-Runner startet neu — die Seitenleiste zieht gleich nach.",
|
||||
"store_job_log": "Log anzeigen",
|
||||
"store_job_dismiss": "Ausblenden",
|
||||
"store_phase_queued": "In der Warteschlange",
|
||||
"store_phase_verifying": "Paket wird verifiziert",
|
||||
"store_phase_installing": "Wird installiert",
|
||||
"store_phase_removing": "Wird entfernt",
|
||||
"store_phase_checking": "Installation wird geprüft",
|
||||
"store_phase_rolling_back": "Wird zurückgerollt",
|
||||
"store_phase_recording": "Herkunft wird vermerkt",
|
||||
"store_phase_restarting": "Plugin-Runner startet neu",
|
||||
"store_phase_done": "Fertig",
|
||||
"games_title": "Laufende Spiele",
|
||||
"games_state_launching": "Startet",
|
||||
"games_state_running": "Läuft",
|
||||
"games_state_exited": "Beendet",
|
||||
"games_state_grace": "Wartet auf Client",
|
||||
"games_closing_in": "Client ist weg – wird in {time} geschlossen, falls er nicht zurückkommt",
|
||||
"games_end_now": "Jetzt beenden",
|
||||
"session_game_title": "Wenn ein Spiel oder eine Sitzung endet",
|
||||
"session_game_help": "Eine Streaming-Sitzung und das Spiel, das sie gestartet hat, können ihr Schicksal teilen. Diese Einstellungen betreffen das Spiel; das Offenhalten oben betrifft die Anzeige, und beide haben eigene Zeitfenster.",
|
||||
"session_game_on_exit": "Wenn das Spiel endet",
|
||||
"session_game_on_exit_help": "Wer das Spiel beendet, landet wieder in der eigenen Bibliothek statt auf deinem Desktop. Schalte es aus, wenn du den Desktop streamst und das Spiel nebensächlich ist.",
|
||||
"session_game_on_exit_end": "Sitzung beenden",
|
||||
"session_game_on_exit_keep": "Weiter streamen",
|
||||
"session_game_end_game": "Wenn die Sitzung endet",
|
||||
"session_game_end_game_help": "Ob das Stoppen (oder der Verlust) einer Sitzung auch das gestartete Spiel schließt. Gilt nie für ein Spiel, das du selbst gestartet hast – nur für eines, das dieser Host für die Sitzung gestartet hat.",
|
||||
"session_game_end_keep": "Weiterlaufen lassen",
|
||||
"session_game_end_on_quit": "Beim Stoppen schließen",
|
||||
"session_game_end_always": "Immer schließen",
|
||||
"session_game_always_warning": "Ein Spiel zu schließen kostet alles, was es nicht gespeichert hat. Der Host bittet es zuerst höflich und erzwingt es nur, wenn es sich weigert – aber ein Verbindungsabbruch ist kein bewusstes Stoppen, deshalb bekommt ein abgerissener Client erst das Zeitfenster unten. Eine dauerhaft offen gehaltene Anzeige bleibt davon unberührt: Diese Einstellung regelt das Spiel, nicht den Bildschirm.",
|
||||
"session_game_grace": "Zeitfenster für die Rückkehr",
|
||||
"session_game_grace_help": "Wie lange ein verschwundener Client Zeit hat zurückzukommen, bevor sein Spiel geschlossen wird. Die Konsole zeigt den Countdown; eine neue Verbindung bricht ihn ab.",
|
||||
"session_game_saved": "Sitzungs- und Spieleinstellungen gespeichert",
|
||||
"session_game_inert": "Dieser Host kann keine Spiele starten – hier bewirken diese Einstellungen nichts"
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"app_name": "Punktfunk",
|
||||
"app_tagline": "Verwaltungskonsole",
|
||||
"display_settings_saved": "Display-Konfiguration gespeichert",
|
||||
"nav_dashboard": "Übersicht",
|
||||
"nav_host": "Host",
|
||||
"nav_displays": "Virtuelle Anzeigen",
|
||||
"nav_clients": "Gekoppelte Geräte",
|
||||
"nav_pairing": "Kopplung",
|
||||
"nav_library": "Bibliothek",
|
||||
"nav_plugins": "Plugins",
|
||||
"plugin_offline_title": "Dieses Plugin läuft nicht",
|
||||
"plugin_offline_hint": "Starte den Scripting-Runner und versuche es erneut.",
|
||||
"plugin_retry": "Erneut versuchen",
|
||||
"plugin_open_new_tab": "In neuem Tab öffnen",
|
||||
"nav_settings": "Einstellungen",
|
||||
"nav_more": "Mehr",
|
||||
"status_title": "Live-Status",
|
||||
"status_video": "Video",
|
||||
"status_audio": "Audio",
|
||||
"status_streaming": "Aktiv",
|
||||
"status_idle": "Inaktiv",
|
||||
"status_session": "Sitzung",
|
||||
"status_sessions_active": "{count} aktiv",
|
||||
"status_no_session": "Keine aktive Sitzung",
|
||||
"status_paired_count": "Gekoppelte Geräte",
|
||||
"status_pin_pending": "Kopplungs-PIN ausstehend",
|
||||
"stream_codec": "Codec",
|
||||
"stream_resolution": "Auflösung",
|
||||
"stream_fps": "Bildrate",
|
||||
"stream_bitrate": "Bitrate",
|
||||
"action_stop_session": "Sitzung beenden",
|
||||
"action_request_idr": "Keyframe anfordern",
|
||||
"action_unpair": "Entkoppeln",
|
||||
"host_identity": "Identität",
|
||||
"host_hostname": "Hostname",
|
||||
"host_local_ip": "Lokale IP",
|
||||
"host_version": "Version",
|
||||
"host_abi": "ABI-Version",
|
||||
"host_codecs": "Codecs",
|
||||
"host_ports": "Ports",
|
||||
"host_uniqueid": "Eindeutige ID",
|
||||
"host_compositors": "Compositoren",
|
||||
"host_compositors_help": "Backends, auf denen der Host eine virtuelle Ausgabe erzeugen kann. Übergib eine ID an das --compositor-Flag eines Clients; der Host nutzt sie, falls verfügbar, sonst per Auto-Erkennung.",
|
||||
"compositor_available": "Verfügbar",
|
||||
"compositor_unavailable": "Nicht verfügbar",
|
||||
"compositor_default": "Standard",
|
||||
"host_gpus": "GPUs",
|
||||
"host_gpus_help": "Die GPU, auf der der Host aufnimmt und encodiert. Automatisch wählt die beste GPU; eine bevorzugte GPU bindet Aufnahme + Encoding an sie. Eine Änderung gilt ab der nächsten Sitzung.",
|
||||
"gpu_automatic": "Automatisch",
|
||||
"gpu_prefer": "Bevorzugen",
|
||||
"gpu_preferred": "Bevorzugt",
|
||||
"gpu_in_use": "In Benutzung · {backend}",
|
||||
"gpu_next_session": "Nächste Sitzung",
|
||||
"gpu_none": "Keine GPUs erkannt.",
|
||||
"gpu_missing_warning": "Die bevorzugte GPU „{name}“ ist nicht vorhanden — stattdessen wird automatisch gewählt.",
|
||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
|
||||
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} bindet das Encoder-Backend.",
|
||||
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} bindet einen {vendor}-Encoder, aber die GPU der nächsten Sitzung ist „{name}“ — die veraltete Bindung sollte aus host.env entfernt werden.",
|
||||
"host_displays": "Virtuelle Displays",
|
||||
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
|
||||
"display_config_title": "Konfiguration",
|
||||
"display_preset": "Voreinstellung",
|
||||
"display_preset_custom": "Benutzerdefiniert",
|
||||
"display_preset_default": "Standard",
|
||||
"display_preset_gaming_rig": "Headless-Box",
|
||||
"display_preset_shared_desktop": "Geteilter Desktop",
|
||||
"display_preset_hotdesk": "Hot-Desk",
|
||||
"display_preset_workstation": "Workstation",
|
||||
"display_keep_alive": "Nach Trennung aktiv halten",
|
||||
"display_keep_alive_off": "Aus",
|
||||
"display_keep_alive_keep": "Behalten für",
|
||||
"display_keep_alive_forever": "Dauerhaft",
|
||||
"display_keep_alive_seconds": "Sekunden",
|
||||
"display_topology": "Topologie",
|
||||
"display_topology_auto": "Automatisch",
|
||||
"display_topology_extend": "Erweitern",
|
||||
"display_topology_primary": "Primär",
|
||||
"display_topology_exclusive": "Exklusiv",
|
||||
"display_max": "Max. Displays",
|
||||
"display_save": "Speichern",
|
||||
"display_effective": "Aktiv",
|
||||
"display_pending_note": "Änderungen greifen ab der nächsten Verbindung — eine laufende Sitzung behält die Anzeige, mit der sie gestartet ist.",
|
||||
"display_live": "Aktive Displays",
|
||||
"display_none_live": "Derzeit keine virtuellen Displays.",
|
||||
"display_state_active": "Aktiv",
|
||||
"display_state_lingering": "Wird gehalten",
|
||||
"display_state_pinned": "Angeheftet",
|
||||
"display_release_btn": "Freigeben",
|
||||
"display_release_all": "Alle gehaltenen freigeben",
|
||||
"display_expires_in": "Abbau in {sec}s",
|
||||
"display_sessions": "{count} streamend",
|
||||
"display_arrange": "Anzeigen anordnen",
|
||||
"display_arrange_help": "Legen Sie fest, wo jede gestreamte Anzeige auf dem Desktop sitzt (in Pixeln). Beim Speichern wird auf ein manuelles Layout umgeschaltet; es greift ab der nächsten Verbindung.",
|
||||
"display_arrange_save": "Anordnung speichern",
|
||||
"display_custom_desc": "Jede Option selbst festlegen.",
|
||||
"display_preset_current": "Aktiv",
|
||||
"display_preset_soon": "in Kürze",
|
||||
"display_keep_alive_help": "„Aus“ baut die Anzeige sofort beim Trennen ab. Halte sie (und bei gamescope ihr Spiel) am Leben, damit ein schnelles Wiederverbinden sofort fortsetzt, statt neu aufzubauen.",
|
||||
"display_topology_help": "Wie sich die gestreamte Anzeige in den Desktop des Hosts einfügt. Erweitern: ein zusätzlicher Bildschirm neben deinen Monitoren. Primär: die gestreamte Anzeige wird der primäre Ausgang, deine Monitore bleiben. Exklusiv: die gestreamte Anzeige ist der einzige Ausgang — physische Monitore werden beim Streamen deaktiviert und danach wiederhergestellt. Auf einem Host ohne Monitor legt es nur fest, ob die gestreamte Anzeige primär ist.",
|
||||
"display_conflict": "Wenn ein weiterer Client verbindet",
|
||||
"display_conflict_help": "Was passiert, wenn ein zweiter Client verbindet, während bereits gestreamt wird, und eine andere Auflösung anfragt.",
|
||||
"display_conflict_separate": "Eigene Anzeige",
|
||||
"display_conflict_steal": "Übernehmen",
|
||||
"display_conflict_join": "Ansicht teilen",
|
||||
"display_conflict_reject": "Besetzt — ablehnen",
|
||||
"display_identity": "Client-Identität",
|
||||
"display_identity_help": "Ob die gestreamte Anzeige eine stabile Client-Identität trägt, sodass der Desktop des Hosts die Monitor-Einstellungen dieses Clients (Skalierung, Auflösung) merkt und beim erneuten Verbinden wieder anwendet. Geteilt: eine Identität für alle. Pro Client: jedes Gerät eigene. Pro Client + Auflösung: separate Einstellungen je Gerät und Auflösung.",
|
||||
"display_game_session": "Dedizierte Spiel-Sitzungen",
|
||||
"display_game_session_help": "Wie eine Sitzung bedient wird, die ein Spiel aus der Bibliothek startet. „Dediziert“ gibt dem Start immer ein eigenes headless-gamescope in genau deiner Auflösung — das Spiel startet direkt, ohne Steam Big Picture, ohne Game-Mode. „Auto“ nutzt die aktuelle Sitzung der Box. gamescope muss installiert sein; sonst fällt Dediziert auf Auto zurück.",
|
||||
"display_game_session_auto": "Auto",
|
||||
"display_game_session_dedicated": "Dediziert",
|
||||
"display_experimental": "Experimentell",
|
||||
"display_ddc": "Monitore beim Streamen ausschalten (DDC/CI)",
|
||||
"display_ddc_help": "Nur Windows, wirkt bei Topologie Exklusiv. Bevor die physischen Monitore deaktiviert werden, weist der Host sie zusätzlich über den DDC/CI-Steuerkanal an, ihr Panel auszuschalten — und weckt sie am Ende des Streams wieder. Das kann ein periodisches Ruckeln beheben, das manche Setups zeigen, wenn die gestreamte Anzeige die einzige aktive ist (der dunkle Monitor sucht sonst weiter seine Eingänge ab; ein schlafen gelegtes Panel nicht). Monitore ohne DDC/CI werden übersprungen — falls ein Monitor danach nicht aufwacht, einmal seinen Power-Knopf drücken und die Option ausschalten.",
|
||||
"display_ddc_disabled": "Aus",
|
||||
"display_ddc_enabled": "Ein",
|
||||
"display_ddc_badge": "Monitore aus via DDC/CI",
|
||||
"display_pnp": "Monitor-Geräte beim Streamen deaktivieren (PnP)",
|
||||
"display_pnp_help": "Nur Windows, wirkt bei Topologie Exklusiv. Zusätzlich zum Entfernen der physischen Monitore vom Desktop deaktiviert der Host deren Windows-Geräteeinträge für die Dauer des Streams und aktiviert sie danach wieder. Ein Monitor oder Fernseher im Standby, der seine Verbindung immer wieder aufweckt (automatische Eingangssuche, Instant-On), kann den Stream so nicht mehr stören — Windows ignoriert seine Aufwach-Ereignisse vollständig, solange das Gerät deaktiviert ist. Stürzt der Host mitten im Stream ab, werden die Monitore beim nächsten Start wieder aktiviert; bis dahin lassen sie sich manuell im Geräte-Manager aktivieren.",
|
||||
"display_pnp_disabled": "Aus",
|
||||
"display_pnp_enabled": "Ein",
|
||||
"display_pnp_badge": "Monitor-Geräte deaktiviert (PnP)",
|
||||
"display_identity_shared": "Geteilt",
|
||||
"display_identity_per_client": "Pro Client",
|
||||
"display_identity_per_client_mode": "Pro Client + Auflösung",
|
||||
"display_layout_mode": "Multi-Monitor-Anordnung",
|
||||
"display_layout_help": "Automatisch ordnet die Anzeigen nebeneinander an (links nach rechts). Manuell: Du platzierst jede selbst — ein X/Y-Editor pro Anzeige erscheint im Abschnitt „Aktive Displays“ unten, sobald zwei oder mehr streamen.",
|
||||
"display_layout_auto_row": "Automatisch (nebeneinander)",
|
||||
"display_layout_manual": "Manuell",
|
||||
"display_preset_custom_label": "Eigene Voreinstellungen",
|
||||
"display_preset_save_as": "Als Voreinstellung speichern…",
|
||||
"display_preset_name": "Name der Voreinstellung",
|
||||
"display_preset_edit": "Umbenennen",
|
||||
"display_preset_update": "Auf aktuelle Einstellungen aktualisieren",
|
||||
"display_preset_delete": "Löschen",
|
||||
"display_preset_delete_confirm": "Diese eigene Voreinstellung löschen?",
|
||||
"clients_title": "Gekoppelte Geräte",
|
||||
"clients_empty": "Noch keine gekoppelten Geräte.",
|
||||
"clients_name": "Name",
|
||||
"clients_fingerprint": "Fingerabdruck",
|
||||
"clients_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
|
||||
"pairing_title": "Kopplung",
|
||||
"pairing_idle": "Keine Kopplung aktiv. Starte die Kopplung in einem Moonlight-Client und gib hier die PIN ein.",
|
||||
"pairing_waiting": "Ein Gerät wartet auf Kopplung. Gib die angezeigte PIN ein:",
|
||||
"pairing_pin_label": "PIN",
|
||||
"pairing_submit": "PIN bestätigen",
|
||||
"pairing_pin_sent": "PIN gesendet. Wenn sie stimmt, schließt der Client die Kopplung ab und erscheint unten unter „Gekoppelte Geräte“.",
|
||||
"pairing_failed": "PIN konnte nicht übermittelt werden — prüfe, ob sie stimmt und der Client noch auf die Kopplung wartet.",
|
||||
"pairing_native_title": "Gerät koppeln",
|
||||
"pairing_native_desc": "Zeige hier eine Einmal-PIN an und gib sie in deiner Punktfunk-App ein, um dieses Gerät zu koppeln.",
|
||||
"pairing_native_disabled": "Der native Host läuft nicht. Starte ihn mit `serve --native`, um Punktfunk-Geräte zu koppeln.",
|
||||
"pairing_native_arm": "Gerät koppeln",
|
||||
"pairing_native_enter": "Gib diese PIN auf deinem Gerät ein:",
|
||||
"pairing_native_expires": "Läuft ab in",
|
||||
"pairing_native_cancel": "Abbrechen",
|
||||
"pairing_native_devices": "Gekoppelte Geräte",
|
||||
"pairing_native_empty": "Noch keine Geräte gekoppelt.",
|
||||
"pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
|
||||
"pairing_protocol": "Protokoll",
|
||||
"pairing_protocol_native": "punktfunk/1",
|
||||
"pairing_protocol_moonlight": "Moonlight",
|
||||
"pairing_pending_title": "Warten auf Freigabe",
|
||||
"pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.",
|
||||
"pairing_pending_approve": "Freigeben",
|
||||
"pairing_pending_deny": "Ablehnen",
|
||||
"pairing_pending_name_prompt": "Gerät benennen:",
|
||||
"pairing_pending_age_just_now": "gerade eben",
|
||||
"pairing_pending_age_secs": "vor {s}s",
|
||||
"pairing_pending_age_mins": "vor {min} min",
|
||||
"pairing_moonlight_title": "Moonlight-Kopplung (GameStream)",
|
||||
"library_title": "Bibliothek",
|
||||
"library_empty": "Noch keine Spiele gefunden.",
|
||||
"library_sources_title": "Spielequellen",
|
||||
"library_sources_help": "Launcher, die dieser Host nach installierten Spielen durchsucht. Eine Quelle abschalten blendet ihre Spiele auf allen Geräten aus — nichts wird gelöscht, und beim Wiedereinschalten sind sie sofort zurück.",
|
||||
"library_sources_failed": "Die Spielequelle konnte nicht aktualisiert werden.",
|
||||
"library_store_steam": "Steam",
|
||||
"library_store_custom": "Eigene",
|
||||
"library_add_title": "Eigenes Spiel hinzufügen",
|
||||
"library_edit_title": "Eigenes Spiel bearbeiten",
|
||||
"library_add_button": "Eigenes Spiel hinzufügen",
|
||||
"library_field_title": "Titel",
|
||||
"library_field_portrait": "Portrait-Bild-URL",
|
||||
"library_field_hero": "Hero-Bild-URL",
|
||||
"library_field_header": "Header-Bild-URL",
|
||||
"library_field_logo": "Logo-Bild-URL",
|
||||
"library_field_command": "Startbefehl",
|
||||
"library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.",
|
||||
"library_field_platform": "Plattform",
|
||||
"library_field_platform_help": "Das System, auf dem dieser Titel läuft, z. B. PS2, Xbox 360, SNES, PC.",
|
||||
"library_field_description": "Beschreibung",
|
||||
"library_field_developer": "Entwickler",
|
||||
"library_field_publisher": "Publisher",
|
||||
"library_field_release_year": "Erscheinungsjahr",
|
||||
"library_field_genres": "Genres",
|
||||
"library_field_genres_help": "Kommagetrennt, z. B. RPG, Plattformer.",
|
||||
"library_field_tags": "Tags",
|
||||
"library_field_tags_help": "Kommagetrennte Labels zum Organisieren, z. B. Koop, Kinder.",
|
||||
"library_field_region": "Region",
|
||||
"library_field_region_help": "z. B. NTSC-U, PAL, NTSC-J.",
|
||||
"library_field_players": "Spieler",
|
||||
"library_details_legend": "Details (optional)",
|
||||
"library_save": "Speichern",
|
||||
"library_create": "Hinzufügen",
|
||||
"library_cancel": "Abbrechen",
|
||||
"library_edit": "Bearbeiten",
|
||||
"library_delete": "Löschen",
|
||||
"library_delete_confirm": "Dieses eigene Spiel löschen? Das kann nicht rückgängig gemacht werden.",
|
||||
"settings_title": "Einstellungen",
|
||||
"settings_token_label": "API-Token",
|
||||
"settings_token_help": "Bearer-Token für die Verwaltungs-API. Bei einem Loopback-Host ohne Token leer lassen.",
|
||||
"settings_language": "Sprache",
|
||||
"settings_save": "Speichern",
|
||||
"settings_saved": "Gespeichert.",
|
||||
"common_loading": "Wird geladen…",
|
||||
"common_error": "Etwas ist schiefgelaufen.",
|
||||
"common_retry": "Erneut versuchen",
|
||||
"common_yes": "Ja",
|
||||
"common_cancel": "Abbrechen",
|
||||
"common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…",
|
||||
"login_title": "Anmelden",
|
||||
"login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren. Du weißt nicht weiter?",
|
||||
"login_docs_link": "Besuche die Dokumentation",
|
||||
"login_password": "Passwort",
|
||||
"login_submit": "Anmelden",
|
||||
"login_error": "Falsches Passwort.",
|
||||
"login_signing_in": "Anmeldung läuft…",
|
||||
"action_logout": "Abmelden",
|
||||
"settings_logout_failed": "Abmelden fehlgeschlagen — du bist weiterhin angemeldet. Bitte versuche es erneut.",
|
||||
"nav_stats": "Leistung",
|
||||
"nav_logs": "Logs",
|
||||
"logs_title": "Logs",
|
||||
"logs_subtitle": "Der aktuelle Log-Stream des Hosts — live verfolgen, nach Level filtern, durchsuchen.",
|
||||
"logs_follow": "Folgen",
|
||||
"logs_pause": "Pause",
|
||||
"logs_clear": "Leeren",
|
||||
"logs_search": "Logs durchsuchen…",
|
||||
"logs_empty": "Keine passenden Logeinträge — Filter anpassen oder auf Host-Aktivität warten.",
|
||||
"logs_dropped": "Einige Einträge wurden verdrängt, bevor sie abgeholt werden konnten",
|
||||
"stats_title": "Leistung",
|
||||
"stats_subtitle": "Zeichne die Pipeline-Zeiten einer Sitzung auf und betrachte sie als Diagramme.",
|
||||
"stats_capture_title": "Aufzeichnung",
|
||||
"stats_capture_desc": "Aufzeichnung scharfschalten, eine Sitzung fahren, dann stoppen, um eine Aufnahme zu speichern. Die Abtastung erfolgt an der Aggregationsgrenze des Hosts — kein Overhead pro Frame.",
|
||||
"stats_recording": "Zeichnet auf",
|
||||
"stats_idle": "Inaktiv",
|
||||
"stats_start": "Aufzeichnung starten",
|
||||
"stats_stop": "Stoppen & speichern",
|
||||
"stats_elapsed": "Vergangen",
|
||||
"stats_samples": "Proben",
|
||||
"stats_kind": "Pfad",
|
||||
"stats_kind_native": "Nativ",
|
||||
"stats_kind_gamestream": "GameStream",
|
||||
"stats_live_title": "Live",
|
||||
"stats_live_waiting": "Scharf — warte auf die ersten Proben. Starte eine Sitzung, um aufzuzeichnen.",
|
||||
"stats_latency_title": "Latenz nach Stufe",
|
||||
"stats_latency_axis": "µs",
|
||||
"stats_latency_desc": "Pipeline-Zeit pro Stufe, gestapelt — die Ansicht „wohin geht die Zeit“.",
|
||||
"stats_throughput_title": "Durchsatz",
|
||||
"stats_health_title": "Zustand",
|
||||
"stats_fps_new": "Neue fps",
|
||||
"stats_fps_repeat": "Wiederholte fps",
|
||||
"stats_mbps": "Mb/s",
|
||||
"stats_bitrate_target": "Ziel-Mb/s",
|
||||
"stats_health_gamestream_note": "Paketverlust, Sende-Drops und FEC-Wiederherstellung sind empfängerseitige Werte, die der GameStream-Pfad nicht erfasst — hier werden nur Frame-Drops angezeigt.",
|
||||
"stats_p99": "p99",
|
||||
"stats_p50": "p50",
|
||||
"stats_frames_dropped": "Verworfene Frames",
|
||||
"stats_packets_dropped": "Verworfene Pakete",
|
||||
"stats_send_dropped": "Sende-Verluste",
|
||||
"stats_fec_recovered": "FEC wiederhergestellt",
|
||||
"stats_recordings_title": "Aufnahmen",
|
||||
"stats_recordings_empty": "Noch keine Aufnahmen. Starte eine Aufzeichnung, um eine anzulegen.",
|
||||
"stats_col_time": "Zeit",
|
||||
"stats_col_kind": "Pfad",
|
||||
"stats_col_resolution": "Auflösung",
|
||||
"stats_col_codec": "Codec",
|
||||
"stats_col_duration": "Dauer",
|
||||
"stats_col_samples": "Proben",
|
||||
"stats_view": "Ansehen",
|
||||
"stats_download": "Herunterladen",
|
||||
"stats_delete": "Löschen",
|
||||
"stats_delete_confirm": "Diese Aufnahme löschen? Das kann nicht rückgängig gemacht werden.",
|
||||
"stats_detail_title": "Aufnahme-Details",
|
||||
"stats_close": "Schließen",
|
||||
"stats_no_samples": "Diese Aufnahme enthält keine Proben.",
|
||||
"store_title": "Plugins",
|
||||
"store_subtitle": "Durchstöbere den Plugin-Katalog, installiere und entferne Plugins und lege fest, welchen Katalogen dieser Host vertraut.",
|
||||
"store_tab_browse": "Entdecken",
|
||||
"store_tab_installed": "Installiert",
|
||||
"store_tab_sources": "Quellen",
|
||||
"store_tier_verified": "Geprüft",
|
||||
"store_tier_verified_hint": "Aus dem eingebauten unom-Katalog — unom hat genau dieses Paket geprüft.",
|
||||
"store_tier_external": "Externe Quelle",
|
||||
"store_tier_external_hint": "Festgepinnt und auf Integrität geprüft, aber von jemand anderem als unom kuratiert. Niemand bei unom hat diesen Code geprüft.",
|
||||
"store_tier_unverified": "Ungeprüft",
|
||||
"store_tier_unverified_hint": "Aus einer rohen Paketangabe installiert. Kein Katalog, keine Prüfung — diesen Code hat nie jemand kontrolliert.",
|
||||
"store_tier_cli": "Über die CLI installiert",
|
||||
"store_tier_cli_hint": "Von der Kommandozeile installiert — der Host weiß daher nicht, woher es stammt.",
|
||||
"store_from_source": "von",
|
||||
"store_search_placeholder": "Plugins durchsuchen…",
|
||||
"store_filter_all": "Alle Quellen",
|
||||
"store_empty": "Noch keine Plugins im Katalog.",
|
||||
"store_no_match": "Kein Plugin passt zur Suche.",
|
||||
"store_by_author": "von {author}",
|
||||
"store_homepage": "Website",
|
||||
"store_install": "Installieren",
|
||||
"store_installed_label": "Installiert",
|
||||
"store_update_to": "Auf {version} aktualisieren",
|
||||
"store_incompatible": "Nicht kompatibel mit diesem Host.",
|
||||
"store_blocked": "Vom Host blockiert: {reason}",
|
||||
"store_spec_open": "Aus Paketangabe installieren…",
|
||||
"store_runner_title": "Plugin-Runner",
|
||||
"store_runner_help": "Der Dienst, in dem jedes Plugin läuft. Ihn abzuschalten stoppt alle Plugins auf einmal, ohne etwas zu deinstallieren.",
|
||||
"store_runner_not_installed": "Der Plugin-Runner ist auf diesem Host nicht installiert — Plugins können daher nicht starten.",
|
||||
"store_runner_banner": "Der Plugin-Runner ist ausgeschaltet — Plugins starten erst, wenn du ihn aktivierst.",
|
||||
"store_runner_enable": "Runner aktivieren",
|
||||
"store_runner_disable": "Runner deaktivieren",
|
||||
"store_runner_state_running": "Läuft",
|
||||
"store_runner_state_stopped": "Gestoppt",
|
||||
"store_runner_state_disabled": "Deaktiviert",
|
||||
"store_runner_state_missing": "Nicht installiert",
|
||||
"store_runner_unit": "Dienst",
|
||||
"store_runner_principal": "Läuft als",
|
||||
"store_runner_failed": "Der Plugin-Runner konnte nicht umgeschaltet werden.",
|
||||
"store_installed_title": "Installierte Plugins",
|
||||
"store_installed_empty": "Noch keine Plugins installiert.",
|
||||
"store_running": "Läuft",
|
||||
"store_stopped": "Läuft nicht",
|
||||
"store_uninstall": "Deinstallieren",
|
||||
"store_uninstall_confirm": "{title} deinstallieren? Du kannst es jederzeit wieder aus dem Katalog installieren.",
|
||||
"store_uninstall_failed": "Die Deinstallation konnte nicht gestartet werden.",
|
||||
"store_update_no_entry": "Dieses Plugin steckt derzeit in keinem Katalog — aktualisiere die Quellen und versuche es erneut.",
|
||||
"store_sources_title": "Katalogquellen",
|
||||
"store_sources_help": "Wo dieser Host nach Plugins sucht. Der eingebaute unom-Katalog ist immer dabei; jede weitere Quelle hast du selbst hinzugefügt und stehst selbst dafür ein.",
|
||||
"store_refresh_all": "Alle aktualisieren",
|
||||
"store_refresh_failed": "Die Kataloge konnten nicht aktualisiert werden.",
|
||||
"store_source_builtin": "Eingebaut",
|
||||
"store_source_signed": "Signiert",
|
||||
"store_source_unsigned": "Unsigniert",
|
||||
"store_source_stale": "Veraltet",
|
||||
"store_source_entries": "{count} Plugins",
|
||||
"store_source_fetched": "zuletzt geladen {when}",
|
||||
"store_source_never": "nie",
|
||||
"store_source_remove": "Quelle entfernen",
|
||||
"store_source_remove_confirm": "Die Quelle „{name}“ entfernen? Bereits daraus installierte Plugins bleiben installiert.",
|
||||
"store_source_remove_failed": "Die Quelle konnte nicht entfernt werden.",
|
||||
"store_source_builtin_locked": "Der eingebaute unom-Katalog kann nicht entfernt werden.",
|
||||
"store_add_source_title": "Katalogquelle hinzufügen",
|
||||
"store_field_source_name": "Name",
|
||||
"store_field_source_url": "Index-URL",
|
||||
"store_field_source_key": "Öffentlicher Schlüssel (optional)",
|
||||
"store_field_source_key_help": "Ein ed25519:…-Schlüssel. Ist er gesetzt, akzeptiert der Host von dieser Quelle nur einen signierten Index.",
|
||||
"store_add_source": "Quelle hinzufügen",
|
||||
"store_add_source_failed": "Die Quelle konnte nicht gespeichert werden — prüfe Name und URL.",
|
||||
"store_source_trust_title": "Dieser Quelle vertrauen?",
|
||||
"store_source_trust_body": "Alles, was du aus „{name}“ installierst, ist Code, den unom nicht geprüft hat. Er läuft auf diesem Host mit den Rechten des Plugin-Runners. Füge nur einen Katalog hinzu, dessen Betreiber du vertraust.",
|
||||
"store_source_trust_unsigned": "Ohne öffentlichen Schlüssel kann der Host nicht erkennen, ob dieser Index unterwegs manipuliert wurde.",
|
||||
"store_source_trust_confirm": "Verstanden — Quelle hinzufügen",
|
||||
"store_install_title": "{title} installieren?",
|
||||
"store_install_verified_body": "Version {version} aus dem eingebauten unom-Katalog. unom hat genau dieses Paket geprüft.",
|
||||
"store_install_confirm": "Installieren",
|
||||
"store_install_external_title": "{title} aus einer externen Quelle installieren?",
|
||||
"store_install_external_body": "Version {version} stammt aus „{source}“ — einem Katalog, den du selbst hinzugefügt hast.",
|
||||
"store_install_external_note": "unom hat diesen Code nicht geprüft. Das Paket ist festgepinnt und auf Integrität geprüft, läuft auf diesem Host aber mit den Rechten des Plugin-Runners.",
|
||||
"store_install_external_confirm": "Trotzdem installieren",
|
||||
"store_install_failed": "Die Installation konnte nicht gestartet werden.",
|
||||
"store_busy": "Der Host installiert oder entfernt gerade schon ein Plugin. Versuche es, sobald das durch ist.",
|
||||
"store_spec_title": "Aus einer Paketangabe installieren",
|
||||
"store_spec_lead": "Das installiert Code direkt aus einer Paket-Registry. Er steht in keinem Katalog, niemand hat ihn geprüft, und er läuft auf diesem Host mit den Rechten des Plugin-Runners — mit demselben Zugriff auf deine Dateien und deine Sitzung.",
|
||||
"store_spec_permanent": "Ein so installiertes Plugin bleibt als „Ungeprüft“ markiert, solange es installiert ist.",
|
||||
"store_spec_field": "Paketangabe",
|
||||
"store_spec_field_help": "Zum Beispiel @scope/plugin-name@1.2.3.",
|
||||
"store_spec_confirm_field": "Gib die Paketangabe zur Bestätigung erneut ein",
|
||||
"store_spec_checkbox": "Mir ist klar, dass hier ungeprüfter Code mit Betreiberrechten ausgeführt wird.",
|
||||
"store_spec_confirm": "Ungeprüft installieren",
|
||||
"store_job_install": "{target} wird installiert",
|
||||
"store_job_uninstall": "{target} wird entfernt",
|
||||
"store_job_done_install": "Installiert.",
|
||||
"store_job_done_uninstall": "Entfernt.",
|
||||
"store_job_failed": "Der Vorgang ist fehlgeschlagen.",
|
||||
"store_job_restarting": "Der Plugin-Runner startet neu — die Seitenleiste zieht gleich nach.",
|
||||
"store_job_log": "Log anzeigen",
|
||||
"store_job_dismiss": "Ausblenden",
|
||||
"store_phase_queued": "In der Warteschlange",
|
||||
"store_phase_verifying": "Paket wird verifiziert",
|
||||
"store_phase_installing": "Wird installiert",
|
||||
"store_phase_removing": "Wird entfernt",
|
||||
"store_phase_checking": "Installation wird geprüft",
|
||||
"store_phase_rolling_back": "Wird zurückgerollt",
|
||||
"store_phase_recording": "Herkunft wird vermerkt",
|
||||
"store_phase_restarting": "Plugin-Runner startet neu",
|
||||
"store_phase_done": "Fertig",
|
||||
"games_title": "Laufende Spiele",
|
||||
"games_state_launching": "Startet",
|
||||
"games_state_running": "Läuft",
|
||||
"games_state_exited": "Beendet",
|
||||
"games_state_grace": "Wartet auf Client",
|
||||
"games_closing_in": "Client ist weg – wird in {time} geschlossen, falls er nicht zurückkommt",
|
||||
"games_end_now": "Jetzt beenden",
|
||||
"session_game_title": "Wenn ein Spiel oder eine Sitzung endet",
|
||||
"session_game_help": "Eine Streaming-Sitzung und das Spiel, das sie gestartet hat, können ihr Schicksal teilen. Diese Einstellungen betreffen das Spiel; das Offenhalten oben betrifft die Anzeige, und beide haben eigene Zeitfenster.",
|
||||
"session_game_on_exit": "Wenn das Spiel endet",
|
||||
"session_game_on_exit_help": "Wer das Spiel beendet, landet wieder in der eigenen Bibliothek statt auf deinem Desktop. Schalte es aus, wenn du den Desktop streamst und das Spiel nebensächlich ist.",
|
||||
"session_game_on_exit_end": "Sitzung beenden",
|
||||
"session_game_on_exit_keep": "Weiter streamen",
|
||||
"session_game_end_game": "Wenn die Sitzung endet",
|
||||
"session_game_end_game_help": "Ob das Stoppen (oder der Verlust) einer Sitzung auch das gestartete Spiel schließt. Gilt nie für ein Spiel, das du selbst gestartet hast – nur für eines, das dieser Host für die Sitzung gestartet hat.",
|
||||
"session_game_end_keep": "Weiterlaufen lassen",
|
||||
"session_game_end_on_quit": "Beim Stoppen schließen",
|
||||
"session_game_end_always": "Immer schließen",
|
||||
"session_game_always_warning": "Ein Spiel zu schließen kostet alles, was es nicht gespeichert hat. Der Host bittet es zuerst höflich und erzwingt es nur, wenn es sich weigert – aber ein Verbindungsabbruch ist kein bewusstes Stoppen, deshalb bekommt ein abgerissener Client erst das Zeitfenster unten. Eine dauerhaft offen gehaltene Anzeige bleibt davon unberührt: Diese Einstellung regelt das Spiel, nicht den Bildschirm.",
|
||||
"session_game_grace": "Zeitfenster für die Rückkehr",
|
||||
"session_game_grace_help": "Wie lange ein verschwundener Client Zeit hat zurückzukommen, bevor sein Spiel geschlossen wird. Die Konsole zeigt den Countdown; eine neue Verbindung bricht ihn ab.",
|
||||
"session_game_saved": "Sitzungs- und Spieleinstellungen gespeichert",
|
||||
"session_game_inert": "Dieser Host kann keine Spiele starten – hier bewirken diese Einstellungen nichts",
|
||||
"session_game_nested_note": "In einer gamescope-Spielsitzung läuft das Spiel *innerhalb* der gestreamten Anzeige und lebt daher genau so lange wie diese – das entscheidet „Offen halten“ oben, nicht diese Einstellung. Ein bewusstes Stoppen reißt die Anzeige sofort ab und nimmt das Spiel mit, egal was du hier wählst.",
|
||||
"display_monitor_title": "Übertragener Bildschirm",
|
||||
"display_monitor_intro": "Standardmäßig erstellt der Host für jeden Client einen eigenen virtuellen Bildschirm in dessen Auflösung. Stattdessen kannst du einen Bildschirm übertragen, den dieser Computer bereits hat — was auf diesem Monitor zu sehen ist, sieht auch der Client.",
|
||||
"display_monitor_virtual": "Virtueller Bildschirm (Standard)",
|
||||
"display_monitor_virtual_hint": "Jeder Client bekommt einen eigenen Bildschirm in eigener Auflösung.",
|
||||
"display_monitor_mirror_hint": "Alle Clients sehen diesen Monitor in dessen Auflösung.",
|
||||
"display_monitor_none": "Dieser Host meldet keine Monitore.",
|
||||
"display_monitor_unavailable": "Monitore konnten auf diesem Host nicht ermittelt werden.",
|
||||
"display_monitor_env_locked": "Auf diesem Host über PUNKTFUNK_CAPTURE_MONITOR festgelegt — dort entfernen, um hier zu wählen.",
|
||||
"display_monitor_primary": "primär",
|
||||
"display_monitor_disabled": "aus",
|
||||
"display_monitor_saved": "Übertragener Bildschirm gespeichert"
|
||||
}
|
||||
|
||||
+427
-415
@@ -1,417 +1,429 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"app_name": "Punktfunk",
|
||||
"app_tagline": "management console",
|
||||
"display_settings_saved": "Display configuration saved",
|
||||
"nav_dashboard": "Dashboard",
|
||||
"nav_host": "Host",
|
||||
"nav_displays": "Virtual displays",
|
||||
"nav_clients": "Paired clients",
|
||||
"nav_pairing": "Pairing",
|
||||
"nav_library": "Library",
|
||||
"nav_settings": "Settings",
|
||||
"nav_more": "More",
|
||||
"nav_plugins": "Plugins",
|
||||
"plugin_offline_title": "This plugin isn't running",
|
||||
"plugin_offline_hint": "Start the scripting runner, then retry.",
|
||||
"plugin_retry": "Retry",
|
||||
"plugin_open_new_tab": "Open in new tab",
|
||||
"status_title": "Live status",
|
||||
"status_video": "Video",
|
||||
"status_audio": "Audio",
|
||||
"status_streaming": "Streaming",
|
||||
"status_idle": "Idle",
|
||||
"status_session": "Session",
|
||||
"status_sessions_active": "{count} active",
|
||||
"status_no_session": "No active session",
|
||||
"status_paired_count": "Paired clients",
|
||||
"status_pin_pending": "Pairing PIN pending",
|
||||
"stream_codec": "Codec",
|
||||
"stream_resolution": "Resolution",
|
||||
"stream_fps": "Frame rate",
|
||||
"stream_bitrate": "Bitrate",
|
||||
"action_stop_session": "Stop session",
|
||||
"action_request_idr": "Request keyframe",
|
||||
"action_unpair": "Unpair",
|
||||
"host_identity": "Identity",
|
||||
"host_hostname": "Hostname",
|
||||
"host_local_ip": "Local IP",
|
||||
"host_version": "Version",
|
||||
"host_abi": "ABI version",
|
||||
"host_codecs": "Codecs",
|
||||
"host_ports": "Ports",
|
||||
"host_uniqueid": "Unique ID",
|
||||
"host_compositors": "Compositors",
|
||||
"host_compositors_help": "Backends the host can drive a virtual output on. Pass an id to a client's --compositor flag; the host honors it if available, else auto-detects.",
|
||||
"compositor_available": "Available",
|
||||
"compositor_unavailable": "Unavailable",
|
||||
"compositor_default": "Default",
|
||||
"host_gpus": "GPUs",
|
||||
"host_gpus_help": "The GPU the host captures and encodes on. Automatic picks the best GPU; preferring one pins capture + encode to it. A change applies to the next session.",
|
||||
"gpu_automatic": "Automatic",
|
||||
"gpu_prefer": "Prefer",
|
||||
"gpu_preferred": "Preferred",
|
||||
"gpu_in_use": "In use · {backend}",
|
||||
"gpu_next_session": "Next session",
|
||||
"gpu_none": "No GPUs detected.",
|
||||
"gpu_missing_warning": "The preferred GPU “{name}” is not present — automatic selection is used instead.",
|
||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
|
||||
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} pins the encoder backend.",
|
||||
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} pins a {vendor} encoder, but the next session's GPU is “{name}” — remove the stale pin from host.env.",
|
||||
"host_displays": "Virtual displays",
|
||||
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
|
||||
"display_config_title": "Configuration",
|
||||
"display_preset": "Preset",
|
||||
"display_preset_custom": "Custom",
|
||||
"display_preset_default": "Default",
|
||||
"display_preset_gaming_rig": "Headless box",
|
||||
"display_preset_shared_desktop": "Shared desktop",
|
||||
"display_preset_hotdesk": "Hot-desk",
|
||||
"display_preset_workstation": "Workstation",
|
||||
"display_keep_alive": "Keep alive after disconnect",
|
||||
"display_keep_alive_off": "Off",
|
||||
"display_keep_alive_keep": "Keep for",
|
||||
"display_keep_alive_forever": "Forever",
|
||||
"display_keep_alive_seconds": "seconds",
|
||||
"display_topology": "Topology",
|
||||
"display_topology_auto": "Automatic",
|
||||
"display_topology_extend": "Extend",
|
||||
"display_topology_primary": "Primary",
|
||||
"display_topology_exclusive": "Exclusive",
|
||||
"display_max": "Max displays",
|
||||
"display_save": "Save",
|
||||
"display_effective": "In effect",
|
||||
"display_pending_note": "Changes apply from the next connection — a streaming session keeps the display it opened on.",
|
||||
"display_live": "Live displays",
|
||||
"display_none_live": "No virtual displays right now.",
|
||||
"display_state_active": "Active",
|
||||
"display_state_lingering": "Lingering",
|
||||
"display_state_pinned": "Pinned",
|
||||
"display_release_btn": "Release",
|
||||
"display_release_all": "Release all kept",
|
||||
"display_expires_in": "tears down in {sec}s",
|
||||
"display_sessions": "{count} streaming",
|
||||
"display_arrange": "Arrange displays",
|
||||
"display_arrange_help": "Set where each streamed display sits on the desktop, in pixels. Saving switches to a manual layout; it applies from the next connect.",
|
||||
"display_arrange_save": "Save arrangement",
|
||||
"display_custom_desc": "Set every option yourself.",
|
||||
"display_preset_current": "Active",
|
||||
"display_preset_soon": "coming soon",
|
||||
"display_keep_alive_help": "Off tears the display down as soon as the client disconnects. Keep it alive (and, on gamescope, its game) so a quick reconnect resumes instantly instead of rebuilding.",
|
||||
"display_topology_help": "How the streamed display fits into the host's desktop. Extend: an extra screen alongside your monitors. Primary: the streamed display becomes the primary output, your monitors kept. Exclusive: the streamed display is the sole output — physical monitors are disabled while streaming and restored after. On a headless host it just sets whether the streamed display is the primary.",
|
||||
"display_conflict": "When another client connects",
|
||||
"display_conflict_help": "What happens if a second client connects while one is already streaming and asks for a different resolution.",
|
||||
"display_conflict_separate": "Own display",
|
||||
"display_conflict_steal": "Take over",
|
||||
"display_conflict_join": "Share view",
|
||||
"display_conflict_reject": "Busy — reject",
|
||||
"display_identity": "Per-client identity",
|
||||
"display_identity_help": "Whether the streamed display carries a stable per-client identity, so the host's desktop remembers that client's per-monitor settings (scaling, resolution) and reapplies them when it reconnects. Shared: one identity for everyone. Per client: each device keeps its own. Per client + resolution: a device keeps separate settings per resolution it connects at.",
|
||||
"display_game_session": "Dedicated game sessions",
|
||||
"display_game_session_help": "How a session that launches a game from the library is served. “Dedicated” always gives the launch its own headless gamescope at your exact resolution — the game boots straight in, no Steam Big Picture, no game mode. “Auto” uses whatever session the box is in. gamescope must be installed; otherwise Dedicated falls back to Auto.",
|
||||
"display_game_session_auto": "Auto",
|
||||
"display_game_session_dedicated": "Dedicated",
|
||||
"display_experimental": "Experimental",
|
||||
"display_ddc": "Turn monitors off while streaming (DDC/CI)",
|
||||
"display_ddc_help": "Windows only, takes effect with Exclusive topology. Before disabling your physical monitors, the host also tells them to power their panel off over the DDC/CI monitor-control channel, and wakes them again when the stream ends. This can eliminate a periodic stutter some setups see when the streamed display is the only active one (the dark monitor keeps probing its inputs; a panel that was told to sleep doesn't). Monitors without DDC/CI support are skipped — if your monitor doesn't wake up afterwards, press its power button once and turn this off.",
|
||||
"display_ddc_disabled": "Off",
|
||||
"display_ddc_enabled": "On",
|
||||
"display_ddc_badge": "Monitors off via DDC/CI",
|
||||
"display_pnp": "Disable monitor devices while streaming (PnP)",
|
||||
"display_pnp_help": "Windows only, takes effect with Exclusive topology. On top of removing your physical monitors from the desktop, the host disables their Windows device entries for the duration of the stream and re-enables them afterwards. A standby monitor or TV that keeps waking its connection (auto input scan, instant-on) can no longer interrupt the stream — Windows ignores its wake events entirely while the device is disabled. If the host crashes mid-stream, the monitors are re-enabled the next time it starts; until then you can re-enable them manually in Device Manager.",
|
||||
"display_pnp_disabled": "Off",
|
||||
"display_pnp_enabled": "On",
|
||||
"display_pnp_badge": "Monitor devices disabled (PnP)",
|
||||
"display_identity_shared": "Shared",
|
||||
"display_identity_per_client": "Per client",
|
||||
"display_identity_per_client_mode": "Per client + resolution",
|
||||
"display_layout_mode": "Multi-monitor layout",
|
||||
"display_layout_help": "Auto lays displays out side by side, left to right. Manual: you position each one yourself — a per-display X/Y editor appears in the Live displays section below once two or more are streaming.",
|
||||
"display_layout_auto_row": "Auto (side by side)",
|
||||
"display_layout_manual": "Manual",
|
||||
"display_preset_custom_label": "Custom presets",
|
||||
"display_preset_save_as": "Save as preset…",
|
||||
"display_preset_name": "Preset name",
|
||||
"display_preset_edit": "Rename",
|
||||
"display_preset_update": "Update to current settings",
|
||||
"display_preset_delete": "Delete",
|
||||
"display_preset_delete_confirm": "Delete this custom preset?",
|
||||
"clients_title": "Paired clients",
|
||||
"clients_empty": "No paired clients yet.",
|
||||
"clients_name": "Name",
|
||||
"clients_fingerprint": "Fingerprint",
|
||||
"clients_unpair_confirm": "Unpair this client? It will need to pair again to connect.",
|
||||
"pairing_title": "Pairing",
|
||||
"pairing_idle": "No pairing in progress. Start pairing from a Moonlight client, then enter its PIN here.",
|
||||
"pairing_waiting": "A client is waiting to pair. Enter the PIN it shows:",
|
||||
"pairing_pin_label": "PIN",
|
||||
"pairing_submit": "Submit PIN",
|
||||
"pairing_pin_sent": "PIN sent. If it's correct, the client finishes pairing and appears under Paired devices below.",
|
||||
"pairing_failed": "Couldn't submit the PIN — check it's correct and that the client is still waiting to pair.",
|
||||
"pairing_native_title": "Pair a device",
|
||||
"pairing_native_desc": "Show a one-time PIN here, then enter it in your Punktfunk app to pair this device.",
|
||||
"pairing_native_disabled": "The native host isn't running. Start it with `serve --native` to pair Punktfunk devices.",
|
||||
"pairing_native_arm": "Pair a device",
|
||||
"pairing_native_enter": "Enter this PIN on your device:",
|
||||
"pairing_native_expires": "Expires in",
|
||||
"pairing_native_cancel": "Cancel",
|
||||
"pairing_native_devices": "Paired devices",
|
||||
"pairing_native_empty": "No devices paired yet.",
|
||||
"pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.",
|
||||
"pairing_protocol": "Protocol",
|
||||
"pairing_protocol_native": "punktfunk/1",
|
||||
"pairing_protocol_moonlight": "Moonlight",
|
||||
"pairing_pending_title": "Waiting for approval",
|
||||
"pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.",
|
||||
"pairing_pending_approve": "Approve",
|
||||
"pairing_pending_deny": "Deny",
|
||||
"pairing_pending_name_prompt": "Name this device:",
|
||||
"pairing_pending_age_just_now": "just now",
|
||||
"pairing_pending_age_secs": "{s}s ago",
|
||||
"pairing_pending_age_mins": "{min} min ago",
|
||||
"pairing_moonlight_title": "Moonlight (GameStream) pairing",
|
||||
"library_title": "Library",
|
||||
"library_empty": "No games found yet.",
|
||||
"library_sources_title": "Game sources",
|
||||
"library_sources_help": "Launchers this host scans for installed games. Turn one off to hide its games from every device — nothing is deleted, and turning it back on brings them right back.",
|
||||
"library_sources_failed": "Could not update the game source.",
|
||||
"library_store_steam": "Steam",
|
||||
"library_store_custom": "Custom",
|
||||
"library_add_title": "Add a custom game",
|
||||
"library_edit_title": "Edit custom game",
|
||||
"library_add_button": "Add custom game",
|
||||
"library_field_title": "Title",
|
||||
"library_field_portrait": "Portrait art URL",
|
||||
"library_field_hero": "Hero art URL",
|
||||
"library_field_header": "Header art URL",
|
||||
"library_field_logo": "Logo art URL",
|
||||
"library_field_command": "Launch command",
|
||||
"library_field_command_help": "Optional. The command the host runs to launch this title.",
|
||||
"library_field_platform": "Platform",
|
||||
"library_field_platform_help": "The system this title runs on, e.g. PS2, Xbox 360, SNES, PC.",
|
||||
"library_field_description": "Description",
|
||||
"library_field_developer": "Developer",
|
||||
"library_field_publisher": "Publisher",
|
||||
"library_field_release_year": "Release year",
|
||||
"library_field_genres": "Genres",
|
||||
"library_field_genres_help": "Comma-separated, e.g. RPG, Platformer.",
|
||||
"library_field_tags": "Tags",
|
||||
"library_field_tags_help": "Comma-separated labels for organizing, e.g. co-op, kids.",
|
||||
"library_field_region": "Region",
|
||||
"library_field_region_help": "e.g. NTSC-U, PAL, NTSC-J.",
|
||||
"library_field_players": "Players",
|
||||
"library_details_legend": "Details (optional)",
|
||||
"library_save": "Save",
|
||||
"library_create": "Add",
|
||||
"library_cancel": "Cancel",
|
||||
"library_edit": "Edit",
|
||||
"library_delete": "Delete",
|
||||
"library_delete_confirm": "Delete this custom game? This can't be undone.",
|
||||
"settings_title": "Settings",
|
||||
"settings_token_label": "API token",
|
||||
"settings_token_help": "Bearer token for the management API. Leave empty for a loopback host with no token.",
|
||||
"settings_language": "Language",
|
||||
"settings_save": "Save",
|
||||
"settings_saved": "Saved.",
|
||||
"common_loading": "Loading…",
|
||||
"common_error": "Something went wrong.",
|
||||
"common_retry": "Retry",
|
||||
"common_yes": "Yes",
|
||||
"common_cancel": "Cancel",
|
||||
"common_unauthorized": "Session expired — redirecting to sign in…",
|
||||
"login_title": "Sign in",
|
||||
"login_subtitle": "Enter the management password to continue. Don't know what to do?",
|
||||
"login_docs_link": "Visit the documentation",
|
||||
"login_password": "Password",
|
||||
"login_submit": "Sign in",
|
||||
"login_error": "Wrong password.",
|
||||
"login_signing_in": "Signing in…",
|
||||
"action_logout": "Sign out",
|
||||
"settings_logout_failed": "Sign-out failed — you're still signed in. Please try again.",
|
||||
"nav_stats": "Performance",
|
||||
"nav_logs": "Logs",
|
||||
"logs_title": "Logs",
|
||||
"logs_subtitle": "The host's recent log stream — follow live, filter by level, search.",
|
||||
"logs_follow": "Follow",
|
||||
"logs_pause": "Pause",
|
||||
"logs_clear": "Clear",
|
||||
"logs_search": "Search logs…",
|
||||
"logs_empty": "No log entries match — adjust the filter or wait for host activity.",
|
||||
"logs_dropped": "Some entries were evicted before they could be fetched",
|
||||
"stats_title": "Performance",
|
||||
"stats_subtitle": "Record a session's pipeline timings and review them as graphs.",
|
||||
"stats_capture_title": "Capture",
|
||||
"stats_capture_desc": "Arm capture, run a session, then stop to save a recording. Sampling runs at the host's aggregation boundary — no per-frame overhead.",
|
||||
"stats_recording": "Recording",
|
||||
"stats_idle": "Idle",
|
||||
"stats_start": "Start capture",
|
||||
"stats_stop": "Stop & save",
|
||||
"stats_elapsed": "Elapsed",
|
||||
"stats_samples": "Samples",
|
||||
"stats_kind": "Path",
|
||||
"stats_kind_native": "Native",
|
||||
"stats_kind_gamestream": "GameStream",
|
||||
"stats_live_title": "Live",
|
||||
"stats_live_waiting": "Armed — waiting for the first samples. Start a session to begin recording.",
|
||||
"stats_latency_title": "Latency by stage",
|
||||
"stats_latency_axis": "µs",
|
||||
"stats_latency_desc": "Per-stage pipeline time, stacked — the \"where does the time go\" view.",
|
||||
"stats_throughput_title": "Throughput",
|
||||
"stats_health_title": "Health",
|
||||
"stats_fps_new": "New fps",
|
||||
"stats_fps_repeat": "Repeat fps",
|
||||
"stats_mbps": "Mb/s",
|
||||
"stats_bitrate_target": "Target Mb/s",
|
||||
"stats_health_gamestream_note": "Packet loss, send-drops, and FEC recovery are receiver-side metrics the GameStream path doesn't instrument — only frame drops are shown here.",
|
||||
"stats_p99": "p99",
|
||||
"stats_p50": "p50",
|
||||
"stats_frames_dropped": "Frames dropped",
|
||||
"stats_packets_dropped": "Packets dropped",
|
||||
"stats_send_dropped": "Send drops",
|
||||
"stats_fec_recovered": "FEC recovered",
|
||||
"stats_recordings_title": "Recordings",
|
||||
"stats_recordings_empty": "No recordings yet. Start a capture to record one.",
|
||||
"stats_col_time": "Time",
|
||||
"stats_col_kind": "Path",
|
||||
"stats_col_resolution": "Resolution",
|
||||
"stats_col_codec": "Codec",
|
||||
"stats_col_duration": "Duration",
|
||||
"stats_col_samples": "Samples",
|
||||
"stats_view": "View",
|
||||
"stats_download": "Download",
|
||||
"stats_delete": "Delete",
|
||||
"stats_delete_confirm": "Delete this recording? This can't be undone.",
|
||||
"stats_detail_title": "Recording detail",
|
||||
"stats_close": "Close",
|
||||
"stats_no_samples": "This recording has no samples.",
|
||||
"store_title": "Plugins",
|
||||
"store_subtitle": "Browse the plugin catalog, install and remove plugins, and choose which catalogs this host trusts.",
|
||||
"store_tab_browse": "Browse",
|
||||
"store_tab_installed": "Installed",
|
||||
"store_tab_sources": "Sources",
|
||||
"store_tier_verified": "Verified",
|
||||
"store_tier_verified_hint": "From the built-in unom catalog — unom reviewed this exact package.",
|
||||
"store_tier_external": "External source",
|
||||
"store_tier_external_hint": "Pinned and integrity-checked, but curated by someone other than unom. Nobody at unom reviewed this code.",
|
||||
"store_tier_unverified": "Unverified",
|
||||
"store_tier_unverified_hint": "Installed from a raw package spec. No catalog, no review — nobody checked this code.",
|
||||
"store_tier_cli": "Installed via CLI",
|
||||
"store_tier_cli_hint": "Installed from the command line, so the host has no record of where it came from.",
|
||||
"store_from_source": "from",
|
||||
"store_search_placeholder": "Search plugins…",
|
||||
"store_filter_all": "All sources",
|
||||
"store_empty": "No plugins in the catalog yet.",
|
||||
"store_no_match": "No plugin matches your search.",
|
||||
"store_by_author": "by {author}",
|
||||
"store_homepage": "Homepage",
|
||||
"store_install": "Install",
|
||||
"store_installed_label": "Installed",
|
||||
"store_update_to": "Update to {version}",
|
||||
"store_incompatible": "Not compatible with this host.",
|
||||
"store_blocked": "Blocked by the host: {reason}",
|
||||
"store_spec_open": "Install from package spec…",
|
||||
"store_runner_title": "Plugin runner",
|
||||
"store_runner_help": "The service every plugin runs inside. Disabling it stops all plugins at once without uninstalling anything.",
|
||||
"store_runner_not_installed": "The plugin runner isn't installed on this host, so plugins can't start.",
|
||||
"store_runner_banner": "The plugin runner is switched off — plugins won't start until you enable it.",
|
||||
"store_runner_enable": "Enable runner",
|
||||
"store_runner_disable": "Disable runner",
|
||||
"store_runner_state_running": "Running",
|
||||
"store_runner_state_stopped": "Stopped",
|
||||
"store_runner_state_disabled": "Disabled",
|
||||
"store_runner_state_missing": "Not installed",
|
||||
"store_runner_unit": "Service",
|
||||
"store_runner_principal": "Runs as",
|
||||
"store_runner_failed": "Could not change the plugin runner.",
|
||||
"store_installed_title": "Installed plugins",
|
||||
"store_installed_empty": "No plugins installed yet.",
|
||||
"store_running": "Running",
|
||||
"store_stopped": "Not running",
|
||||
"store_uninstall": "Uninstall",
|
||||
"store_uninstall_confirm": "Uninstall {title}? You can install it again from the catalog.",
|
||||
"store_uninstall_failed": "Could not start the removal.",
|
||||
"store_update_no_entry": "That plugin isn't in any catalog right now — refresh the sources and try again.",
|
||||
"store_sources_title": "Catalog sources",
|
||||
"store_sources_help": "Where this host looks for plugins. The built-in unom catalog is always present; every other source is one you added and vouch for yourself.",
|
||||
"store_refresh_all": "Refresh all",
|
||||
"store_refresh_failed": "Could not refresh the catalogs.",
|
||||
"store_source_builtin": "Built in",
|
||||
"store_source_signed": "Signed",
|
||||
"store_source_unsigned": "Unsigned",
|
||||
"store_source_stale": "Out of date",
|
||||
"store_source_entries": "{count} plugins",
|
||||
"store_source_fetched": "last fetched {when}",
|
||||
"store_source_never": "never",
|
||||
"store_source_remove": "Remove source",
|
||||
"store_source_remove_confirm": "Remove the source “{name}”? Plugins already installed from it stay installed.",
|
||||
"store_source_remove_failed": "Could not remove the source.",
|
||||
"store_source_builtin_locked": "The built-in unom catalog can't be removed.",
|
||||
"store_add_source_title": "Add a catalog source",
|
||||
"store_field_source_name": "Name",
|
||||
"store_field_source_url": "Index URL",
|
||||
"store_field_source_key": "Public key (optional)",
|
||||
"store_field_source_key_help": "An ed25519:… key. With a key set, the host only accepts a signed index from this source.",
|
||||
"store_add_source": "Add source",
|
||||
"store_add_source_failed": "Could not save the source — check the name and the URL.",
|
||||
"store_source_trust_title": "Trust this source?",
|
||||
"store_source_trust_body": "Everything you install from “{name}” is code unom has not reviewed. It runs on this host with the plugin runner's privileges. Only add a catalog whose operator you trust.",
|
||||
"store_source_trust_unsigned": "Without a public key the host can't tell whether this index was tampered with in transit.",
|
||||
"store_source_trust_confirm": "I understand — add the source",
|
||||
"store_install_title": "Install {title}?",
|
||||
"store_install_verified_body": "Version {version} from the built-in unom catalog. unom reviewed this exact package.",
|
||||
"store_install_confirm": "Install",
|
||||
"store_install_external_title": "Install {title} from an external source?",
|
||||
"store_install_external_body": "Version {version} comes from “{source}”, a catalog you added yourself.",
|
||||
"store_install_external_note": "unom has not reviewed this code. The package is pinned and integrity-checked, but it will run on this host with the plugin runner's privileges.",
|
||||
"store_install_external_confirm": "Install anyway",
|
||||
"store_install_failed": "Could not start the install.",
|
||||
"store_busy": "The host is already installing or removing a plugin. Try again once it's finished.",
|
||||
"store_spec_title": "Install from a package spec",
|
||||
"store_spec_lead": "This installs code straight from a package registry. It is in no catalog, nobody has reviewed it, and it will run on this host with the plugin runner's privileges — the same access it has to your files and your session.",
|
||||
"store_spec_permanent": "A plugin installed this way stays marked Unverified for as long as it is installed.",
|
||||
"store_spec_field": "Package spec",
|
||||
"store_spec_field_help": "For example @scope/plugin-name@1.2.3.",
|
||||
"store_spec_confirm_field": "Type the package spec again to confirm",
|
||||
"store_spec_checkbox": "I understand that this runs unreviewed code with operator privileges.",
|
||||
"store_spec_confirm": "Install unverified",
|
||||
"store_job_install": "Installing {target}",
|
||||
"store_job_uninstall": "Removing {target}",
|
||||
"store_job_done_install": "Installed.",
|
||||
"store_job_done_uninstall": "Removed.",
|
||||
"store_job_failed": "The job failed.",
|
||||
"store_job_restarting": "The plugin runner is restarting — the sidebar catches up in a moment.",
|
||||
"store_job_log": "Show log",
|
||||
"store_job_dismiss": "Dismiss",
|
||||
"store_phase_queued": "Queued",
|
||||
"store_phase_verifying": "Verifying the package",
|
||||
"store_phase_installing": "Installing",
|
||||
"store_phase_removing": "Removing",
|
||||
"store_phase_checking": "Checking the install",
|
||||
"store_phase_rolling_back": "Rolling back",
|
||||
"store_phase_recording": "Recording provenance",
|
||||
"store_phase_restarting": "Restarting the plugin runner",
|
||||
"store_phase_done": "Done",
|
||||
"games_title": "Running games",
|
||||
"games_state_launching": "Starting",
|
||||
"games_state_running": "Running",
|
||||
"games_state_exited": "Ended",
|
||||
"games_state_grace": "Waiting for client",
|
||||
"games_closing_in": "Its client is gone — closing in {time} unless it comes back",
|
||||
"games_end_now": "End now",
|
||||
"session_game_title": "When a game or a session ends",
|
||||
"session_game_help": "A streaming session and the game it launched can share a fate. These settings are about the game; the keep-alive above is about the display, and the two have separate timers.",
|
||||
"session_game_on_exit": "When the game exits",
|
||||
"session_game_on_exit_help": "Quitting the game hands the client back to its own library instead of leaving it on your desktop. Turn this off if you stream the desktop and treat the game as incidental.",
|
||||
"session_game_on_exit_end": "End the session",
|
||||
"session_game_on_exit_keep": "Keep streaming",
|
||||
"session_game_end_game": "When the session ends",
|
||||
"session_game_end_game_help": "Whether stopping (or losing) a session also closes the game it launched. Never applies to a game you started yourself — only one this host launched for the session.",
|
||||
"session_game_end_keep": "Leave it running",
|
||||
"session_game_end_on_quit": "Close it on Stop",
|
||||
"session_game_end_always": "Always close it",
|
||||
"session_game_always_warning": "Closing a game costs whatever it had not saved. The host asks it to close first and only forces the issue if it refuses — but a network drop is not someone pressing Stop, so a dropped client gets the reconnect window below before anything happens. A display kept forever stays up regardless; this setting governs the game, not the screen.",
|
||||
"session_game_grace": "Reconnect window",
|
||||
"session_game_grace_help": "How long a client that vanished has to come back before its game is closed. The console shows the countdown, and reconnecting cancels it.",
|
||||
"session_game_saved": "Session and game settings saved",
|
||||
"session_game_inert": "This host has no way to launch games, so these settings do nothing here"
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"app_name": "Punktfunk",
|
||||
"app_tagline": "management console",
|
||||
"display_settings_saved": "Display configuration saved",
|
||||
"nav_dashboard": "Dashboard",
|
||||
"nav_host": "Host",
|
||||
"nav_displays": "Virtual displays",
|
||||
"nav_clients": "Paired clients",
|
||||
"nav_pairing": "Pairing",
|
||||
"nav_library": "Library",
|
||||
"nav_settings": "Settings",
|
||||
"nav_more": "More",
|
||||
"nav_plugins": "Plugins",
|
||||
"plugin_offline_title": "This plugin isn't running",
|
||||
"plugin_offline_hint": "Start the scripting runner, then retry.",
|
||||
"plugin_retry": "Retry",
|
||||
"plugin_open_new_tab": "Open in new tab",
|
||||
"status_title": "Live status",
|
||||
"status_video": "Video",
|
||||
"status_audio": "Audio",
|
||||
"status_streaming": "Streaming",
|
||||
"status_idle": "Idle",
|
||||
"status_session": "Session",
|
||||
"status_sessions_active": "{count} active",
|
||||
"status_no_session": "No active session",
|
||||
"status_paired_count": "Paired clients",
|
||||
"status_pin_pending": "Pairing PIN pending",
|
||||
"stream_codec": "Codec",
|
||||
"stream_resolution": "Resolution",
|
||||
"stream_fps": "Frame rate",
|
||||
"stream_bitrate": "Bitrate",
|
||||
"action_stop_session": "Stop session",
|
||||
"action_request_idr": "Request keyframe",
|
||||
"action_unpair": "Unpair",
|
||||
"host_identity": "Identity",
|
||||
"host_hostname": "Hostname",
|
||||
"host_local_ip": "Local IP",
|
||||
"host_version": "Version",
|
||||
"host_abi": "ABI version",
|
||||
"host_codecs": "Codecs",
|
||||
"host_ports": "Ports",
|
||||
"host_uniqueid": "Unique ID",
|
||||
"host_compositors": "Compositors",
|
||||
"host_compositors_help": "Backends the host can drive a virtual output on. Pass an id to a client's --compositor flag; the host honors it if available, else auto-detects.",
|
||||
"compositor_available": "Available",
|
||||
"compositor_unavailable": "Unavailable",
|
||||
"compositor_default": "Default",
|
||||
"host_gpus": "GPUs",
|
||||
"host_gpus_help": "The GPU the host captures and encodes on. Automatic picks the best GPU; preferring one pins capture + encode to it. A change applies to the next session.",
|
||||
"gpu_automatic": "Automatic",
|
||||
"gpu_prefer": "Prefer",
|
||||
"gpu_preferred": "Preferred",
|
||||
"gpu_in_use": "In use · {backend}",
|
||||
"gpu_next_session": "Next session",
|
||||
"gpu_none": "No GPUs detected.",
|
||||
"gpu_missing_warning": "The preferred GPU “{name}” is not present — automatic selection is used instead.",
|
||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
|
||||
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} pins the encoder backend.",
|
||||
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} pins a {vendor} encoder, but the next session's GPU is “{name}” — remove the stale pin from host.env.",
|
||||
"host_displays": "Virtual displays",
|
||||
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
|
||||
"display_config_title": "Configuration",
|
||||
"display_preset": "Preset",
|
||||
"display_preset_custom": "Custom",
|
||||
"display_preset_default": "Default",
|
||||
"display_preset_gaming_rig": "Headless box",
|
||||
"display_preset_shared_desktop": "Shared desktop",
|
||||
"display_preset_hotdesk": "Hot-desk",
|
||||
"display_preset_workstation": "Workstation",
|
||||
"display_keep_alive": "Keep alive after disconnect",
|
||||
"display_keep_alive_off": "Off",
|
||||
"display_keep_alive_keep": "Keep for",
|
||||
"display_keep_alive_forever": "Forever",
|
||||
"display_keep_alive_seconds": "seconds",
|
||||
"display_topology": "Topology",
|
||||
"display_topology_auto": "Automatic",
|
||||
"display_topology_extend": "Extend",
|
||||
"display_topology_primary": "Primary",
|
||||
"display_topology_exclusive": "Exclusive",
|
||||
"display_max": "Max displays",
|
||||
"display_save": "Save",
|
||||
"display_effective": "In effect",
|
||||
"display_pending_note": "Changes apply from the next connection — a streaming session keeps the display it opened on.",
|
||||
"display_live": "Live displays",
|
||||
"display_none_live": "No virtual displays right now.",
|
||||
"display_state_active": "Active",
|
||||
"display_state_lingering": "Lingering",
|
||||
"display_state_pinned": "Pinned",
|
||||
"display_release_btn": "Release",
|
||||
"display_release_all": "Release all kept",
|
||||
"display_expires_in": "tears down in {sec}s",
|
||||
"display_sessions": "{count} streaming",
|
||||
"display_arrange": "Arrange displays",
|
||||
"display_arrange_help": "Set where each streamed display sits on the desktop, in pixels. Saving switches to a manual layout; it applies from the next connect.",
|
||||
"display_arrange_save": "Save arrangement",
|
||||
"display_custom_desc": "Set every option yourself.",
|
||||
"display_preset_current": "Active",
|
||||
"display_preset_soon": "coming soon",
|
||||
"display_keep_alive_help": "Off tears the display down as soon as the client disconnects. Keep it alive (and, on gamescope, its game) so a quick reconnect resumes instantly instead of rebuilding.",
|
||||
"display_topology_help": "How the streamed display fits into the host's desktop. Extend: an extra screen alongside your monitors. Primary: the streamed display becomes the primary output, your monitors kept. Exclusive: the streamed display is the sole output — physical monitors are disabled while streaming and restored after. On a headless host it just sets whether the streamed display is the primary.",
|
||||
"display_conflict": "When another client connects",
|
||||
"display_conflict_help": "What happens if a second client connects while one is already streaming and asks for a different resolution.",
|
||||
"display_conflict_separate": "Own display",
|
||||
"display_conflict_steal": "Take over",
|
||||
"display_conflict_join": "Share view",
|
||||
"display_conflict_reject": "Busy — reject",
|
||||
"display_identity": "Per-client identity",
|
||||
"display_identity_help": "Whether the streamed display carries a stable per-client identity, so the host's desktop remembers that client's per-monitor settings (scaling, resolution) and reapplies them when it reconnects. Shared: one identity for everyone. Per client: each device keeps its own. Per client + resolution: a device keeps separate settings per resolution it connects at.",
|
||||
"display_game_session": "Dedicated game sessions",
|
||||
"display_game_session_help": "How a session that launches a game from the library is served. “Dedicated” always gives the launch its own headless gamescope at your exact resolution — the game boots straight in, no Steam Big Picture, no game mode. “Auto” uses whatever session the box is in. gamescope must be installed; otherwise Dedicated falls back to Auto.",
|
||||
"display_game_session_auto": "Auto",
|
||||
"display_game_session_dedicated": "Dedicated",
|
||||
"display_experimental": "Experimental",
|
||||
"display_ddc": "Turn monitors off while streaming (DDC/CI)",
|
||||
"display_ddc_help": "Windows only, takes effect with Exclusive topology. Before disabling your physical monitors, the host also tells them to power their panel off over the DDC/CI monitor-control channel, and wakes them again when the stream ends. This can eliminate a periodic stutter some setups see when the streamed display is the only active one (the dark monitor keeps probing its inputs; a panel that was told to sleep doesn't). Monitors without DDC/CI support are skipped — if your monitor doesn't wake up afterwards, press its power button once and turn this off.",
|
||||
"display_ddc_disabled": "Off",
|
||||
"display_ddc_enabled": "On",
|
||||
"display_ddc_badge": "Monitors off via DDC/CI",
|
||||
"display_pnp": "Disable monitor devices while streaming (PnP)",
|
||||
"display_pnp_help": "Windows only, takes effect with Exclusive topology. On top of removing your physical monitors from the desktop, the host disables their Windows device entries for the duration of the stream and re-enables them afterwards. A standby monitor or TV that keeps waking its connection (auto input scan, instant-on) can no longer interrupt the stream — Windows ignores its wake events entirely while the device is disabled. If the host crashes mid-stream, the monitors are re-enabled the next time it starts; until then you can re-enable them manually in Device Manager.",
|
||||
"display_pnp_disabled": "Off",
|
||||
"display_pnp_enabled": "On",
|
||||
"display_pnp_badge": "Monitor devices disabled (PnP)",
|
||||
"display_identity_shared": "Shared",
|
||||
"display_identity_per_client": "Per client",
|
||||
"display_identity_per_client_mode": "Per client + resolution",
|
||||
"display_layout_mode": "Multi-monitor layout",
|
||||
"display_layout_help": "Auto lays displays out side by side, left to right. Manual: you position each one yourself — a per-display X/Y editor appears in the Live displays section below once two or more are streaming.",
|
||||
"display_layout_auto_row": "Auto (side by side)",
|
||||
"display_layout_manual": "Manual",
|
||||
"display_preset_custom_label": "Custom presets",
|
||||
"display_preset_save_as": "Save as preset…",
|
||||
"display_preset_name": "Preset name",
|
||||
"display_preset_edit": "Rename",
|
||||
"display_preset_update": "Update to current settings",
|
||||
"display_preset_delete": "Delete",
|
||||
"display_preset_delete_confirm": "Delete this custom preset?",
|
||||
"clients_title": "Paired clients",
|
||||
"clients_empty": "No paired clients yet.",
|
||||
"clients_name": "Name",
|
||||
"clients_fingerprint": "Fingerprint",
|
||||
"clients_unpair_confirm": "Unpair this client? It will need to pair again to connect.",
|
||||
"pairing_title": "Pairing",
|
||||
"pairing_idle": "No pairing in progress. Start pairing from a Moonlight client, then enter its PIN here.",
|
||||
"pairing_waiting": "A client is waiting to pair. Enter the PIN it shows:",
|
||||
"pairing_pin_label": "PIN",
|
||||
"pairing_submit": "Submit PIN",
|
||||
"pairing_pin_sent": "PIN sent. If it's correct, the client finishes pairing and appears under Paired devices below.",
|
||||
"pairing_failed": "Couldn't submit the PIN — check it's correct and that the client is still waiting to pair.",
|
||||
"pairing_native_title": "Pair a device",
|
||||
"pairing_native_desc": "Show a one-time PIN here, then enter it in your Punktfunk app to pair this device.",
|
||||
"pairing_native_disabled": "The native host isn't running. Start it with `serve --native` to pair Punktfunk devices.",
|
||||
"pairing_native_arm": "Pair a device",
|
||||
"pairing_native_enter": "Enter this PIN on your device:",
|
||||
"pairing_native_expires": "Expires in",
|
||||
"pairing_native_cancel": "Cancel",
|
||||
"pairing_native_devices": "Paired devices",
|
||||
"pairing_native_empty": "No devices paired yet.",
|
||||
"pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.",
|
||||
"pairing_protocol": "Protocol",
|
||||
"pairing_protocol_native": "punktfunk/1",
|
||||
"pairing_protocol_moonlight": "Moonlight",
|
||||
"pairing_pending_title": "Waiting for approval",
|
||||
"pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.",
|
||||
"pairing_pending_approve": "Approve",
|
||||
"pairing_pending_deny": "Deny",
|
||||
"pairing_pending_name_prompt": "Name this device:",
|
||||
"pairing_pending_age_just_now": "just now",
|
||||
"pairing_pending_age_secs": "{s}s ago",
|
||||
"pairing_pending_age_mins": "{min} min ago",
|
||||
"pairing_moonlight_title": "Moonlight (GameStream) pairing",
|
||||
"library_title": "Library",
|
||||
"library_empty": "No games found yet.",
|
||||
"library_sources_title": "Game sources",
|
||||
"library_sources_help": "Launchers this host scans for installed games. Turn one off to hide its games from every device — nothing is deleted, and turning it back on brings them right back.",
|
||||
"library_sources_failed": "Could not update the game source.",
|
||||
"library_store_steam": "Steam",
|
||||
"library_store_custom": "Custom",
|
||||
"library_add_title": "Add a custom game",
|
||||
"library_edit_title": "Edit custom game",
|
||||
"library_add_button": "Add custom game",
|
||||
"library_field_title": "Title",
|
||||
"library_field_portrait": "Portrait art URL",
|
||||
"library_field_hero": "Hero art URL",
|
||||
"library_field_header": "Header art URL",
|
||||
"library_field_logo": "Logo art URL",
|
||||
"library_field_command": "Launch command",
|
||||
"library_field_command_help": "Optional. The command the host runs to launch this title.",
|
||||
"library_field_platform": "Platform",
|
||||
"library_field_platform_help": "The system this title runs on, e.g. PS2, Xbox 360, SNES, PC.",
|
||||
"library_field_description": "Description",
|
||||
"library_field_developer": "Developer",
|
||||
"library_field_publisher": "Publisher",
|
||||
"library_field_release_year": "Release year",
|
||||
"library_field_genres": "Genres",
|
||||
"library_field_genres_help": "Comma-separated, e.g. RPG, Platformer.",
|
||||
"library_field_tags": "Tags",
|
||||
"library_field_tags_help": "Comma-separated labels for organizing, e.g. co-op, kids.",
|
||||
"library_field_region": "Region",
|
||||
"library_field_region_help": "e.g. NTSC-U, PAL, NTSC-J.",
|
||||
"library_field_players": "Players",
|
||||
"library_details_legend": "Details (optional)",
|
||||
"library_save": "Save",
|
||||
"library_create": "Add",
|
||||
"library_cancel": "Cancel",
|
||||
"library_edit": "Edit",
|
||||
"library_delete": "Delete",
|
||||
"library_delete_confirm": "Delete this custom game? This can't be undone.",
|
||||
"settings_title": "Settings",
|
||||
"settings_token_label": "API token",
|
||||
"settings_token_help": "Bearer token for the management API. Leave empty for a loopback host with no token.",
|
||||
"settings_language": "Language",
|
||||
"settings_save": "Save",
|
||||
"settings_saved": "Saved.",
|
||||
"common_loading": "Loading…",
|
||||
"common_error": "Something went wrong.",
|
||||
"common_retry": "Retry",
|
||||
"common_yes": "Yes",
|
||||
"common_cancel": "Cancel",
|
||||
"common_unauthorized": "Session expired — redirecting to sign in…",
|
||||
"login_title": "Sign in",
|
||||
"login_subtitle": "Enter the management password to continue. Don't know what to do?",
|
||||
"login_docs_link": "Visit the documentation",
|
||||
"login_password": "Password",
|
||||
"login_submit": "Sign in",
|
||||
"login_error": "Wrong password.",
|
||||
"login_signing_in": "Signing in…",
|
||||
"action_logout": "Sign out",
|
||||
"settings_logout_failed": "Sign-out failed — you're still signed in. Please try again.",
|
||||
"nav_stats": "Performance",
|
||||
"nav_logs": "Logs",
|
||||
"logs_title": "Logs",
|
||||
"logs_subtitle": "The host's recent log stream — follow live, filter by level, search.",
|
||||
"logs_follow": "Follow",
|
||||
"logs_pause": "Pause",
|
||||
"logs_clear": "Clear",
|
||||
"logs_search": "Search logs…",
|
||||
"logs_empty": "No log entries match — adjust the filter or wait for host activity.",
|
||||
"logs_dropped": "Some entries were evicted before they could be fetched",
|
||||
"stats_title": "Performance",
|
||||
"stats_subtitle": "Record a session's pipeline timings and review them as graphs.",
|
||||
"stats_capture_title": "Capture",
|
||||
"stats_capture_desc": "Arm capture, run a session, then stop to save a recording. Sampling runs at the host's aggregation boundary — no per-frame overhead.",
|
||||
"stats_recording": "Recording",
|
||||
"stats_idle": "Idle",
|
||||
"stats_start": "Start capture",
|
||||
"stats_stop": "Stop & save",
|
||||
"stats_elapsed": "Elapsed",
|
||||
"stats_samples": "Samples",
|
||||
"stats_kind": "Path",
|
||||
"stats_kind_native": "Native",
|
||||
"stats_kind_gamestream": "GameStream",
|
||||
"stats_live_title": "Live",
|
||||
"stats_live_waiting": "Armed — waiting for the first samples. Start a session to begin recording.",
|
||||
"stats_latency_title": "Latency by stage",
|
||||
"stats_latency_axis": "µs",
|
||||
"stats_latency_desc": "Per-stage pipeline time, stacked — the \"where does the time go\" view.",
|
||||
"stats_throughput_title": "Throughput",
|
||||
"stats_health_title": "Health",
|
||||
"stats_fps_new": "New fps",
|
||||
"stats_fps_repeat": "Repeat fps",
|
||||
"stats_mbps": "Mb/s",
|
||||
"stats_bitrate_target": "Target Mb/s",
|
||||
"stats_health_gamestream_note": "Packet loss, send-drops, and FEC recovery are receiver-side metrics the GameStream path doesn't instrument — only frame drops are shown here.",
|
||||
"stats_p99": "p99",
|
||||
"stats_p50": "p50",
|
||||
"stats_frames_dropped": "Frames dropped",
|
||||
"stats_packets_dropped": "Packets dropped",
|
||||
"stats_send_dropped": "Send drops",
|
||||
"stats_fec_recovered": "FEC recovered",
|
||||
"stats_recordings_title": "Recordings",
|
||||
"stats_recordings_empty": "No recordings yet. Start a capture to record one.",
|
||||
"stats_col_time": "Time",
|
||||
"stats_col_kind": "Path",
|
||||
"stats_col_resolution": "Resolution",
|
||||
"stats_col_codec": "Codec",
|
||||
"stats_col_duration": "Duration",
|
||||
"stats_col_samples": "Samples",
|
||||
"stats_view": "View",
|
||||
"stats_download": "Download",
|
||||
"stats_delete": "Delete",
|
||||
"stats_delete_confirm": "Delete this recording? This can't be undone.",
|
||||
"stats_detail_title": "Recording detail",
|
||||
"stats_close": "Close",
|
||||
"stats_no_samples": "This recording has no samples.",
|
||||
"store_title": "Plugins",
|
||||
"store_subtitle": "Browse the plugin catalog, install and remove plugins, and choose which catalogs this host trusts.",
|
||||
"store_tab_browse": "Browse",
|
||||
"store_tab_installed": "Installed",
|
||||
"store_tab_sources": "Sources",
|
||||
"store_tier_verified": "Verified",
|
||||
"store_tier_verified_hint": "From the built-in unom catalog — unom reviewed this exact package.",
|
||||
"store_tier_external": "External source",
|
||||
"store_tier_external_hint": "Pinned and integrity-checked, but curated by someone other than unom. Nobody at unom reviewed this code.",
|
||||
"store_tier_unverified": "Unverified",
|
||||
"store_tier_unverified_hint": "Installed from a raw package spec. No catalog, no review — nobody checked this code.",
|
||||
"store_tier_cli": "Installed via CLI",
|
||||
"store_tier_cli_hint": "Installed from the command line, so the host has no record of where it came from.",
|
||||
"store_from_source": "from",
|
||||
"store_search_placeholder": "Search plugins…",
|
||||
"store_filter_all": "All sources",
|
||||
"store_empty": "No plugins in the catalog yet.",
|
||||
"store_no_match": "No plugin matches your search.",
|
||||
"store_by_author": "by {author}",
|
||||
"store_homepage": "Homepage",
|
||||
"store_install": "Install",
|
||||
"store_installed_label": "Installed",
|
||||
"store_update_to": "Update to {version}",
|
||||
"store_incompatible": "Not compatible with this host.",
|
||||
"store_blocked": "Blocked by the host: {reason}",
|
||||
"store_spec_open": "Install from package spec…",
|
||||
"store_runner_title": "Plugin runner",
|
||||
"store_runner_help": "The service every plugin runs inside. Disabling it stops all plugins at once without uninstalling anything.",
|
||||
"store_runner_not_installed": "The plugin runner isn't installed on this host, so plugins can't start.",
|
||||
"store_runner_banner": "The plugin runner is switched off — plugins won't start until you enable it.",
|
||||
"store_runner_enable": "Enable runner",
|
||||
"store_runner_disable": "Disable runner",
|
||||
"store_runner_state_running": "Running",
|
||||
"store_runner_state_stopped": "Stopped",
|
||||
"store_runner_state_disabled": "Disabled",
|
||||
"store_runner_state_missing": "Not installed",
|
||||
"store_runner_unit": "Service",
|
||||
"store_runner_principal": "Runs as",
|
||||
"store_runner_failed": "Could not change the plugin runner.",
|
||||
"store_installed_title": "Installed plugins",
|
||||
"store_installed_empty": "No plugins installed yet.",
|
||||
"store_running": "Running",
|
||||
"store_stopped": "Not running",
|
||||
"store_uninstall": "Uninstall",
|
||||
"store_uninstall_confirm": "Uninstall {title}? You can install it again from the catalog.",
|
||||
"store_uninstall_failed": "Could not start the removal.",
|
||||
"store_update_no_entry": "That plugin isn't in any catalog right now — refresh the sources and try again.",
|
||||
"store_sources_title": "Catalog sources",
|
||||
"store_sources_help": "Where this host looks for plugins. The built-in unom catalog is always present; every other source is one you added and vouch for yourself.",
|
||||
"store_refresh_all": "Refresh all",
|
||||
"store_refresh_failed": "Could not refresh the catalogs.",
|
||||
"store_source_builtin": "Built in",
|
||||
"store_source_signed": "Signed",
|
||||
"store_source_unsigned": "Unsigned",
|
||||
"store_source_stale": "Out of date",
|
||||
"store_source_entries": "{count} plugins",
|
||||
"store_source_fetched": "last fetched {when}",
|
||||
"store_source_never": "never",
|
||||
"store_source_remove": "Remove source",
|
||||
"store_source_remove_confirm": "Remove the source “{name}”? Plugins already installed from it stay installed.",
|
||||
"store_source_remove_failed": "Could not remove the source.",
|
||||
"store_source_builtin_locked": "The built-in unom catalog can't be removed.",
|
||||
"store_add_source_title": "Add a catalog source",
|
||||
"store_field_source_name": "Name",
|
||||
"store_field_source_url": "Index URL",
|
||||
"store_field_source_key": "Public key (optional)",
|
||||
"store_field_source_key_help": "An ed25519:… key. With a key set, the host only accepts a signed index from this source.",
|
||||
"store_add_source": "Add source",
|
||||
"store_add_source_failed": "Could not save the source — check the name and the URL.",
|
||||
"store_source_trust_title": "Trust this source?",
|
||||
"store_source_trust_body": "Everything you install from “{name}” is code unom has not reviewed. It runs on this host with the plugin runner's privileges. Only add a catalog whose operator you trust.",
|
||||
"store_source_trust_unsigned": "Without a public key the host can't tell whether this index was tampered with in transit.",
|
||||
"store_source_trust_confirm": "I understand — add the source",
|
||||
"store_install_title": "Install {title}?",
|
||||
"store_install_verified_body": "Version {version} from the built-in unom catalog. unom reviewed this exact package.",
|
||||
"store_install_confirm": "Install",
|
||||
"store_install_external_title": "Install {title} from an external source?",
|
||||
"store_install_external_body": "Version {version} comes from “{source}”, a catalog you added yourself.",
|
||||
"store_install_external_note": "unom has not reviewed this code. The package is pinned and integrity-checked, but it will run on this host with the plugin runner's privileges.",
|
||||
"store_install_external_confirm": "Install anyway",
|
||||
"store_install_failed": "Could not start the install.",
|
||||
"store_busy": "The host is already installing or removing a plugin. Try again once it's finished.",
|
||||
"store_spec_title": "Install from a package spec",
|
||||
"store_spec_lead": "This installs code straight from a package registry. It is in no catalog, nobody has reviewed it, and it will run on this host with the plugin runner's privileges — the same access it has to your files and your session.",
|
||||
"store_spec_permanent": "A plugin installed this way stays marked Unverified for as long as it is installed.",
|
||||
"store_spec_field": "Package spec",
|
||||
"store_spec_field_help": "For example @scope/plugin-name@1.2.3.",
|
||||
"store_spec_confirm_field": "Type the package spec again to confirm",
|
||||
"store_spec_checkbox": "I understand that this runs unreviewed code with operator privileges.",
|
||||
"store_spec_confirm": "Install unverified",
|
||||
"store_job_install": "Installing {target}",
|
||||
"store_job_uninstall": "Removing {target}",
|
||||
"store_job_done_install": "Installed.",
|
||||
"store_job_done_uninstall": "Removed.",
|
||||
"store_job_failed": "The job failed.",
|
||||
"store_job_restarting": "The plugin runner is restarting — the sidebar catches up in a moment.",
|
||||
"store_job_log": "Show log",
|
||||
"store_job_dismiss": "Dismiss",
|
||||
"store_phase_queued": "Queued",
|
||||
"store_phase_verifying": "Verifying the package",
|
||||
"store_phase_installing": "Installing",
|
||||
"store_phase_removing": "Removing",
|
||||
"store_phase_checking": "Checking the install",
|
||||
"store_phase_rolling_back": "Rolling back",
|
||||
"store_phase_recording": "Recording provenance",
|
||||
"store_phase_restarting": "Restarting the plugin runner",
|
||||
"store_phase_done": "Done",
|
||||
"games_title": "Running games",
|
||||
"games_state_launching": "Starting",
|
||||
"games_state_running": "Running",
|
||||
"games_state_exited": "Ended",
|
||||
"games_state_grace": "Waiting for client",
|
||||
"games_closing_in": "Its client is gone — closing in {time} unless it comes back",
|
||||
"games_end_now": "End now",
|
||||
"session_game_title": "When a game or a session ends",
|
||||
"session_game_help": "A streaming session and the game it launched can share a fate. These settings are about the game; the keep-alive above is about the display, and the two have separate timers.",
|
||||
"session_game_on_exit": "When the game exits",
|
||||
"session_game_on_exit_help": "Quitting the game hands the client back to its own library instead of leaving it on your desktop. Turn this off if you stream the desktop and treat the game as incidental.",
|
||||
"session_game_on_exit_end": "End the session",
|
||||
"session_game_on_exit_keep": "Keep streaming",
|
||||
"session_game_end_game": "When the session ends",
|
||||
"session_game_end_game_help": "Whether stopping (or losing) a session also closes the game it launched. Never applies to a game you started yourself — only one this host launched for the session.",
|
||||
"session_game_end_keep": "Leave it running",
|
||||
"session_game_end_on_quit": "Close it on Stop",
|
||||
"session_game_end_always": "Always close it",
|
||||
"session_game_always_warning": "Closing a game costs whatever it had not saved. The host asks it to close first and only forces the issue if it refuses — but a network drop is not someone pressing Stop, so a dropped client gets the reconnect window below before anything happens. A display kept forever stays up regardless; this setting governs the game, not the screen.",
|
||||
"session_game_grace": "Reconnect window",
|
||||
"session_game_grace_help": "How long a client that vanished has to come back before its game is closed. The console shows the countdown, and reconnecting cancels it.",
|
||||
"session_game_saved": "Session and game settings saved",
|
||||
"session_game_inert": "This host has no way to launch games, so these settings do nothing here",
|
||||
"session_game_nested_note": "On a gamescope game session the game runs *inside* the streamed display, so it lives exactly as long as that display does — which is what Keep alive above decides, not this setting. A deliberate Stop tears that display down at once and takes the game with it, whichever option you pick here.",
|
||||
"display_monitor_title": "Streamed screen",
|
||||
"display_monitor_intro": "By default the host creates its own virtual screen for each client, sized to that client. Instead, you can stream one of the screens this computer already has — what you see on that monitor is what the client sees.",
|
||||
"display_monitor_virtual": "Virtual screen (default)",
|
||||
"display_monitor_virtual_hint": "Each client gets its own screen at its own resolution.",
|
||||
"display_monitor_mirror_hint": "Every client sees this monitor, at its resolution.",
|
||||
"display_monitor_none": "This host reports no monitors.",
|
||||
"display_monitor_unavailable": "Monitors could not be listed on this host.",
|
||||
"display_monitor_env_locked": "Pinned by PUNKTFUNK_CAPTURE_MONITOR on this host — unset it to choose here.",
|
||||
"display_monitor_primary": "primary",
|
||||
"display_monitor_disabled": "off",
|
||||
"display_monitor_saved": "Streamed screen saved"
|
||||
}
|
||||
|
||||
+2
-1
@@ -65,6 +65,7 @@
|
||||
"immutable": "^4.3.9",
|
||||
"undici": "^7.28.0",
|
||||
"postcss": "^8.5.10",
|
||||
"js-yaml": "^4.3.0"
|
||||
"js-yaml": "^4.3.0",
|
||||
"brace-expansion": "^5.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,14 +56,20 @@ const GameRow: FC<{
|
||||
const waiting = game.state === "grace";
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-16 w-11 shrink-0 overflow-hidden rounded bg-muted">
|
||||
{art && (
|
||||
{/* Fixed slot so rows line up whether or not a title has a cover. Plenty won't: an
|
||||
operator-typed command has no catalog entry behind it, a custom entry may carry no
|
||||
art, and nothing does until `/library` has loaded — an empty box reads as broken, so
|
||||
the placeholder says "game" instead of nothing. */}
|
||||
<div className="flex h-16 w-11 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||
{art ? (
|
||||
<img
|
||||
src={art}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Gamepad2 className="size-5 text-muted-foreground/60" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -189,6 +189,10 @@ const DisplayForm: FC<{
|
||||
game_session: draft.game_session ?? "auto",
|
||||
ddc_power_off: draft.ddc_power_off ?? false,
|
||||
pnp_disable_monitors: draft.pnp_disable_monitors ?? false,
|
||||
// Which screen we stream is not a display-behavior axis at all — swapping the
|
||||
// streamed screen out from under the operator because they changed a preset would be
|
||||
// the worst kind of surprise.
|
||||
capture_monitor: draft.capture_monitor ?? null,
|
||||
});
|
||||
} else {
|
||||
apply({ ...draft, preset: id as Preset });
|
||||
@@ -206,6 +210,11 @@ const DisplayForm: FC<{
|
||||
// The experimental axes aren't part of a preset — keep the current settings.
|
||||
ddc_power_off: draft.ddc_power_off ?? false,
|
||||
pnp_disable_monitors: draft.pnp_disable_monitors ?? false,
|
||||
// Nor is the streamed screen: this builds a FRESH policy object rather than spreading
|
||||
// the draft, so anything not named here is silently dropped — which is exactly how
|
||||
// applying a saved preset used to switch a mirroring host back to a virtual display
|
||||
// (found on-glass, .136). Every orthogonal axis has to be listed.
|
||||
capture_monitor: draft.capture_monitor ?? null,
|
||||
});
|
||||
|
||||
// A custom card is "current" when the in-force policy is a Custom one whose fields + game-session
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import type { FC } from "react";
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
import {
|
||||
getGetDisplayMonitorsQueryKey,
|
||||
getGetDisplaySettingsQueryKey,
|
||||
useGetDisplayMonitors,
|
||||
useGetDisplaySettings,
|
||||
useSetDisplaySettings,
|
||||
} from "@/api/gen/display/display";
|
||||
import type { ApiMonitorInfo } from "@/api/gen/model";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
* **Streamed screen** (design/per-monitor-portal-capture.md §5.3): stream one of the host's real
|
||||
* monitors instead of creating a virtual display per client.
|
||||
*
|
||||
* Deliberately a HOST-wide choice, not per-client — it is the decision of record for this feature,
|
||||
* and it is also what keeps input honest: the injector is host-lifetime and shared by every
|
||||
* concurrent session, so "which screen do absolute coordinates land on" can only have one answer.
|
||||
*
|
||||
* Saves on selection (like the policy card above) — there is no Save button to miss. The pin is a
|
||||
* field of the display policy, so it rides the same PUT.
|
||||
*/
|
||||
export const MonitorCard: FC = () => {
|
||||
const qc = useQueryClient();
|
||||
const monitors = useGetDisplayMonitors();
|
||||
const settings = useGetDisplaySettings();
|
||||
const save = useSetDisplaySettings();
|
||||
|
||||
const policy = settings.data?.settings;
|
||||
const rows = monitors.data?.monitors ?? [];
|
||||
// The host reports the EFFECTIVE pin — an env-pinned appliance shows its real answer here even
|
||||
// though the console cannot change it.
|
||||
const pinned = monitors.data?.pinned ?? null;
|
||||
// `PUNKTFUNK_CAPTURE_MONITOR` outranks the stored policy, so a host pinned in its unit's
|
||||
// environment is read-only here: offering controls that silently lose to the env would be worse
|
||||
// than saying so.
|
||||
const envLocked = !!pinned && policy?.capture_monitor !== pinned;
|
||||
|
||||
const choose = (connector: string | null) => {
|
||||
if (!policy || envLocked) return;
|
||||
save.mutate(
|
||||
{ data: { ...policy, capture_monitor: connector } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
|
||||
qc.invalidateQueries({ queryKey: getGetDisplayMonitorsQueryKey() });
|
||||
toast.success(m.display_monitor_saved());
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const busy = save.isPending;
|
||||
const error = save.error instanceof ApiError ? save.error.message : undefined;
|
||||
|
||||
const row = (
|
||||
key: string,
|
||||
selected: boolean,
|
||||
title: string,
|
||||
hint: string,
|
||||
tags?: ReturnType<typeof Badge>[],
|
||||
onSelect?: () => void,
|
||||
) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
disabled={busy || envLocked || !onSelect}
|
||||
onClick={onSelect}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"flex w-full items-start justify-between gap-4 rounded-md border p-3 text-left transition-colors",
|
||||
selected ? "border-primary bg-primary/5" : "hover:bg-muted/50",
|
||||
(busy || envLocked) && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
{title}
|
||||
{tags}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">{hint}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const monitorRow = (mon: ApiMonitorInfo) => {
|
||||
const tags = [
|
||||
mon.primary ? (
|
||||
<Badge key="p" variant="secondary">
|
||||
{m.display_monitor_primary()}
|
||||
</Badge>
|
||||
) : null,
|
||||
!mon.enabled ? (
|
||||
<Badge key="d" variant="outline">
|
||||
{m.display_monitor_disabled()}
|
||||
</Badge>
|
||||
) : null,
|
||||
].filter(Boolean) as ReturnType<typeof Badge>[];
|
||||
return row(
|
||||
mon.connector,
|
||||
pinned?.toLowerCase() === mon.connector.toLowerCase(),
|
||||
`${mon.connector} — ${mon.description}`,
|
||||
`${mon.mode} · ${m.display_monitor_mirror_hint()}`,
|
||||
tags,
|
||||
// A disabled head cannot be streamed (the host refuses with that reason), so don't
|
||||
// offer it as a choice — it is listed so "why isn't it here?" has an answer.
|
||||
mon.enabled && !mon.managed ? () => choose(mon.connector) : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{m.display_monitor_title()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
{m.display_monitor_intro()}
|
||||
</p>
|
||||
{envLocked && (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-500">
|
||||
{m.display_monitor_env_locked()}
|
||||
</p>
|
||||
)}
|
||||
<QueryState
|
||||
isLoading={monitors.isLoading}
|
||||
error={monitors.error}
|
||||
refetch={monitors.refetch}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{row(
|
||||
"__virtual__",
|
||||
!pinned,
|
||||
m.display_monitor_virtual(),
|
||||
m.display_monitor_virtual_hint(),
|
||||
undefined,
|
||||
() => choose(null),
|
||||
)}
|
||||
{rows.map(monitorRow)}
|
||||
{rows.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{monitors.data?.error
|
||||
? m.display_monitor_unavailable()
|
||||
: m.display_monitor_none()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</QueryState>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -116,6 +116,14 @@ export const SessionGameCard: FC = () => {
|
||||
{m.session_game_always_warning()}
|
||||
</p>
|
||||
)}
|
||||
{/* Shown for every option, including "leave it running": on a nested
|
||||
gamescope launch the game IS inside the streamed display, so the
|
||||
display's own keep-alive outranks anything chosen here — verified
|
||||
on glass (.41), where a deliberate stop ended the game under
|
||||
`keep`. Worded so a non-gamescope host reads it and moves on. */}
|
||||
<p className="max-w-prose text-xs text-muted-foreground">
|
||||
{m.session_game_nested_note()}
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
{(server.game_on_session_end ?? "keep") === "always" && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FC } from "react";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { DisplaySection } from "./DisplayCard";
|
||||
import { MonitorCard } from "./MonitorCard";
|
||||
import { SessionGameCard } from "./SessionGameCard";
|
||||
|
||||
/**
|
||||
@@ -21,6 +22,7 @@ export const SectionDisplays: FC = () => {
|
||||
<div className="flex flex-col gap-card">
|
||||
<h1 className="text-2xl font-semibold">{m.nav_displays()}</h1>
|
||||
<DisplaySection />
|
||||
<MonitorCard />
|
||||
<SessionGameCard />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
Reference in New Issue
Block a user